Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 74 additions & 7 deletions internal/features/auth/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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),
}
}

Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -291,15 +325,16 @@ 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
}

// Login verifies the password, unwraps the master key with the password-derived
// 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
}

Expand All @@ -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)

Expand All @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -380,6 +433,8 @@ func (s *Service) Login(input LoginInput) (bool, error) {
}
s.dbConfig = config

s.loginLimiter.Reset(input.Username)

return true, nil
}

Expand All @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down
98 changes: 98 additions & 0 deletions internal/platform/ratelimit/ratelimit.go
Original file line number Diff line number Diff line change
@@ -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)
}
8 changes: 7 additions & 1 deletion internal/shared/crypto/encryption.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import (
"fmt"
"io"

sharederrors "ayo/internal/shared/errors"

"github.com/alexedwards/argon2id"
"golang.org/x/crypto/argon2"
)
Expand Down Expand Up @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions internal/shared/errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading