From b9c56fcba41b55b0507c0a8b1cba23059ce81028 Mon Sep 17 00:00:00 2001 From: Abhishek Chatterjee Date: Sun, 30 Aug 2026 20:37:22 +0530 Subject: [PATCH] feat(auth): #68: add in-memory rate limiting for register, login, and reset --- internal/features/auth/service.go | 81 ++++++++++++++++++-- internal/platform/ratelimit/ratelimit.go | 98 ++++++++++++++++++++++++ internal/shared/crypto/encryption.go | 8 +- internal/shared/errors/errors.go | 15 ++++ 4 files changed, 194 insertions(+), 8 deletions(-) create mode 100644 internal/platform/ratelimit/ratelimit.go diff --git a/internal/features/auth/service.go b/internal/features/auth/service.go index 6b74065..226746a 100644 --- a/internal/features/auth/service.go +++ b/internal/features/auth/service.go @@ -3,12 +3,14 @@ package auth import ( "context" stderrors "errors" + "fmt" "os" "regexp" "sync" "time" dbclient "ayo/internal/clients/db" + "ayo/internal/platform/ratelimit" "ayo/internal/shared/crypto" "ayo/internal/shared/dialog" "ayo/internal/shared/errors" @@ -17,6 +19,14 @@ import ( passwordvalidator "github.com/wagslane/go-password-validator" ) +// maxLoginAttempts is the number of consecutive failed attempts allowed for a +// username before further attempts are rejected. +const maxLoginAttempts = 5 + +// loginLockout is how long a username is blocked after exceeding the attempt +// limit. +const loginLockout = 5 * time.Minute + // minPasswordEntropy is the entropy floor, in bits, that a password must meet to // pass the "password_strength" validation rule. It is a policy decision that the // settings feature could later expose to the user. @@ -75,6 +85,16 @@ type Service struct { mu sync.Mutex inactivityTimeoutMinutes int migrationRunner dbclient.MigrationRunner + registerLimiter *ratelimit.Limiter + loginLimiter *ratelimit.Limiter + resetLimiter *ratelimit.Limiter +} + +// tooManyAttemptsError builds the user-facing lockout error, wrapping the +// ErrTooManyAttempts sentinel (so errors.Is still matches) with the remaining +// wait time for a friendlier message. +func tooManyAttemptsError(retryAfter time.Duration) error { + return fmt.Errorf("%w (try again in %s)", errors.ErrTooManyAttempts, retryAfter.Round(time.Second)) } // Startup stores the Wails application context, which native dialogs (e.g. @@ -132,6 +152,9 @@ func NewService(conn *dbclient.Connection, migrationRunner dbclient.MigrationRun validate: validate, inactivityTimeoutMinutes: 15, migrationRunner: migrationRunner, + registerLimiter: ratelimit.New(maxLoginAttempts, loginLockout), + loginLimiter: ratelimit.New(maxLoginAttempts, loginLockout), + resetLimiter: ratelimit.New(maxLoginAttempts, loginLockout), } } @@ -143,8 +166,8 @@ func NewService(conn *dbclient.Connection, migrationRunner dbclient.MigrationRun // or the SQLite location is writable), then the credentials are dual-encrypted // and persisted in the OS keyring. The plaintext recovery key is returned (and // must be shown to the user) exactly once. -func (s *Service) Register(input RegisterInput) (*RegisterResult, error) { - if err := s.validate.Struct(input); err != nil { +func (s *Service) Register(input RegisterInput) (result *RegisterResult, err error) { + if err = s.validate.Struct(input); err != nil { return nil, passwordValidationError(err) } if err := dbclient.ValidateConfig(input.DBConfig); err != nil { @@ -157,6 +180,17 @@ func (s *Service) Register(input RegisterInput) (*RegisterResult, error) { return nil, errors.ErrAlreadySignedIn } + // Reject attempts while this username is locked out for too many failures. + if allowed, retryAfter := s.registerLimiter.Check(input.Username); !allowed { + return nil, tooManyAttemptsError(retryAfter) + } + // Count duplicate-username attempts against the lockout, reset on success. + defer func() { + if stderrors.Is(err, errors.ErrUserAlreadyExists) { + s.registerLimiter.RecordFailure(input.Username) + } + }() + // Reject usernames already taken on this machine: every registered account // has a database-credentials entry in the OS keyring (dbcreds_{username}) // that is never deleted. Without this check, registering the same username @@ -291,6 +325,7 @@ func (s *Service) Register(input RegisterInput) (*RegisterResult, error) { // return the original recovery key to the user so they can store it. The // []byte buffer is wiped on the way out; this string conversion is the one // immutable copy Wails needs for serialization. + s.registerLimiter.Reset(input.Username) return &RegisterResult{User: user, RecoveryKey: string(recoveryKey)}, nil } @@ -298,8 +333,8 @@ func (s *Service) Register(input RegisterInput) (*RegisterResult, error) { // KEK, opens the user's database and stores the resulting session in memory. A // session is not persisted, so the user must log in again after every app // restart. -func (s *Service) Login(input LoginInput) (bool, error) { - if err := s.validate.Struct(input); err != nil { +func (s *Service) Login(input LoginInput) (ok bool, err error) { + if err = s.validate.Struct(input); err != nil { return false, errors.ErrInvalidInput } @@ -309,6 +344,18 @@ func (s *Service) Login(input LoginInput) (bool, error) { return false, errors.ErrAlreadySignedIn } + // Reject attempts while this username is locked out for too many failures. + // This runs before any Argon2 work, so blocked users never burn CPU. + if allowed, retryAfter := s.loginLimiter.Check(input.Username); !allowed { + return false, tooManyAttemptsError(retryAfter) + } + // Count failed credentials against the lockout, reset on success. + defer func() { + if stderrors.Is(err, errors.ErrInvalidPassword) || stderrors.Is(err, errors.ErrUserNotFound) { + s.loginLimiter.RecordFailure(input.Username) + } + }() + passwordBytes := []byte(input.Password) defer crypto.Wipe(passwordBytes) @@ -321,6 +368,12 @@ func (s *Service) Login(input LoginInput) (bool, error) { if stderrors.Is(err, ErrCredentialsNotFound) { return false, errors.ErrUserNotFound } + // A wrong password fails GCM authentication here, before the password + // hash is ever compared. Map it to the same shared error as the hash + // check below so login never distinguishes the two cases. + if stderrors.Is(err, errors.ErrInvalidSecret) { + return false, errors.ErrInvalidPassword + } return false, errors.AsInternalServerError("login: load database credentials", err) } config := creds.ToConfig() @@ -345,7 +398,7 @@ func (s *Service) Login(input LoginInput) (bool, error) { } // comparing the password against the stored Argon2id PHC hash - ok, err := crypto.VerifyPasswordHash(passwordBytes, user.passwordHash) + ok, err = crypto.VerifyPasswordHash(passwordBytes, user.passwordHash) if err != nil || !ok { s.conn.Close() return false, errors.ErrInvalidPassword @@ -380,6 +433,8 @@ func (s *Service) Login(input LoginInput) (bool, error) { } s.dbConfig = config + s.loginLimiter.Reset(input.Username) + return true, nil } @@ -390,11 +445,22 @@ func (s *Service) Login(input LoginInput) (bool, error) { // unwrapped with the recovery key and re-encrypted with the new keys, so the // account keeps its database. The new recovery key is returned and must be // shown to the user exactly once. -func (s *Service) ResetPassword(input ResetPasswordInput) (*RegisterResult, error) { - if err := s.validate.Struct(input); err != nil { +func (s *Service) ResetPassword(input ResetPasswordInput) (result *RegisterResult, err error) { + if err = s.validate.Struct(input); err != nil { return nil, passwordValidationError(err) } + // Reject attempts while this username is locked out for too many failures. + if allowed, retryAfter := s.resetLimiter.Check(input.Username); !allowed { + return nil, tooManyAttemptsError(retryAfter) + } + // Count invalid recovery-key attempts against the lockout, reset on success. + defer func() { + if stderrors.Is(err, errors.ErrInvalidRecoveryKey) || stderrors.Is(err, errors.ErrUserNotFound) { + s.resetLimiter.RecordFailure(input.Username) + } + }() + recoveryKeyBytes := []byte(input.RecoveryKey) defer crypto.Wipe(recoveryKeyBytes) newPasswordBytes := []byte(input.NewPassword) @@ -546,6 +612,7 @@ func (s *Service) ResetPassword(input ResetPasswordInput) (*RegisterResult, erro // The []byte buffer is wiped on the way out; this string conversion is the // one immutable copy Wails needs for serialization. + s.resetLimiter.Reset(input.Username) return &RegisterResult{User: user, RecoveryKey: string(newRecoveryKey)}, nil } diff --git a/internal/platform/ratelimit/ratelimit.go b/internal/platform/ratelimit/ratelimit.go new file mode 100644 index 0000000..6b7420a --- /dev/null +++ b/internal/platform/ratelimit/ratelimit.go @@ -0,0 +1,98 @@ +// Package ratelimit provides a small in-memory lockout limiter. It tracks +// consecutive failed attempts per key and blocks the key for a fixed window +// once a threshold is reached. State is process-local and lost on restart, +// which is acceptable for a desktop app. +// +// All state is guarded by an internal mutex; critical sections are kept short +// (map lookups only), so callers never hold the lock across expensive work such +// as Argon2 derivation. +package ratelimit + +import ( + "sync" + "time" +) + +// entry is the per-key attempt state. +type entry struct { + count int + lockedUntil time.Time +} + +// Limiter tracks failed attempts per key and enforces a lockout window. Create +// one limiter per protected action (e.g. login, register, reset), each with its +// own quota. +type Limiter struct { + mu sync.Mutex + maxAttempts int + lockout time.Duration + entries map[string]*entry +} + +// New returns a Limiter that locks a key for lockout once maxAttempts +// consecutive failures are recorded. +func New(maxAttempts int, lockout time.Duration) *Limiter { + return &Limiter{ + maxAttempts: maxAttempts, + lockout: lockout, + entries: make(map[string]*entry), + } +} + +// Check reports whether the key may proceed. When blocked, retryAfter is the +// remaining lockout duration. An expired lockout clears the key's state, so the +// counter starts fresh after the window passes. +func (l *Limiter) Check(key string) (allowed bool, retryAfter time.Duration) { + l.mu.Lock() + defer l.mu.Unlock() + + e, ok := l.entries[key] + if !ok { + return true, 0 + } + now := time.Now() + if now.Before(e.lockedUntil) { + return false, e.lockedUntil.Sub(now).Round(time.Second) + } + // A lockout that has expired clears the key so the next attempt starts a + // fresh window. Entries that have never tripped a lockout (lockedUntil is + // zero) are left untouched so the failure counter keeps accumulating. + if !e.lockedUntil.IsZero() { + delete(l.entries, key) + } + return true, 0 +} + +// RecordFailure counts one failed attempt for the key and locks it once the +// threshold is reached. The lockout window is measured from the attempt that +// trips it. +func (l *Limiter) RecordFailure(key string) { + l.mu.Lock() + defer l.mu.Unlock() + + e, ok := l.entries[key] + if !ok { + e = &entry{} + l.entries[key] = e + } + now := time.Now() + // A prior lockout may have expired between attempts; start a fresh window. + // (Check normally removes expired entries first, but this guards the + // RecordFailure-only path.) + if !e.lockedUntil.IsZero() && !now.Before(e.lockedUntil) { + e.count = 0 + e.lockedUntil = time.Time{} + } + e.count++ + if e.count >= l.maxAttempts { + e.lockedUntil = now.Add(l.lockout) + } +} + +// Reset clears all tracked state for the key. Call it on a successful attempt +// so prior failures are forgotten. +func (l *Limiter) Reset(key string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.entries, key) +} diff --git a/internal/shared/crypto/encryption.go b/internal/shared/crypto/encryption.go index 324598b..bc94b72 100644 --- a/internal/shared/crypto/encryption.go +++ b/internal/shared/crypto/encryption.go @@ -34,6 +34,8 @@ import ( "fmt" "io" + sharederrors "ayo/internal/shared/errors" + "github.com/alexedwards/argon2id" "golang.org/x/crypto/argon2" ) @@ -261,7 +263,11 @@ func DecryptData(key []byte, ciphertext []byte) ([]byte, error) { } nonce, encryptedData := ciphertext[:nonceSize], ciphertext[nonceSize:] - return aead.Open(nil, nonce, encryptedData, nil) + plaintext, err := aead.Open(nil, nonce, encryptedData, nil) + if err != nil { + return nil, fmt.Errorf("%w: %w", sharederrors.ErrInvalidSecret, err) + } + return plaintext, nil } // dualEncryptedBlob is the JSON shape persisted for a value wrapped twice: diff --git a/internal/shared/errors/errors.go b/internal/shared/errors/errors.go index 2263d11..fad99ec 100644 --- a/internal/shared/errors/errors.go +++ b/internal/shared/errors/errors.go @@ -56,6 +56,21 @@ var ( "the recovery key you entered is incorrect. Please check it and try again", ) + // ErrInvalidSecret is an internal marker, not a user-facing message. It is + // returned when AES-GCM authentication fails during decryption, i.e. the + // supplied key does not match the ciphertext (or the ciphertext was + // tampered with). Callers use it to tell a wrong password or recovery key + // apart from internal decryption failures. + ErrInvalidSecret = errors.New("invalid secret: ciphertext authentication failed") + + // ErrTooManyAttempts means too many consecutive failed attempts for one + // action, so further attempts are rejected until the lockout window passes. + // Services wrap it with the remaining wait time so the message tells the user + // when they can retry. + ErrTooManyAttempts = errors.New( + "too many failed attempts. Please wait and try again later", + ) + // ErrUnauthorized means the caller is not signed in. ErrUnauthorized = errors.New( "you must be logged in to access this resource",