From 702094c3cffa16de0a67727f92bee75e950e411a Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Sun, 30 Aug 2026 06:13:46 +0000 Subject: [PATCH 01/21] fix: return real error instead of swallowing crypto/rand failure CryptoRandTokenGenerator.New() returned ("", nil) when rand.Read failed, silently treating an empty string as a valid token with no error to catch it. rand.Read failing is rare on Linux but not theoretically impossible (exhausted entropy, sandboxed environments), and swallowing the error is a real correctness bug independent of how rare the trigger is. --- token/generator.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/token/generator.go b/token/generator.go index fea8d11..e7474f3 100644 --- a/token/generator.go +++ b/token/generator.go @@ -37,7 +37,7 @@ func NewCryptoRandTokenGenerator(byteLength int) (*CryptoRandTokenGenerator, err func (g *CryptoRandTokenGenerator) New() (string, error) { buf := make([]byte, g.ByteLength) if _, err := rand.Read(buf); err != nil { - return "", nil + return "", err } return hex.EncodeToString(buf), nil } From da264a3de2f4d1407948d1168f434a35d3afd1d2 Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Sun, 30 Aug 2026 06:13:51 +0000 Subject: [PATCH 02/21] test: add regression coverage for the swallowed rand.Read error Swaps crypto/rand.Reader for a deterministic failing reader to exercise the New() error path, which never fails in practice under normal conditions and so had no prior coverage. --- token/generator_rand_error_test.go | 41 ++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 token/generator_rand_error_test.go diff --git a/token/generator_rand_error_test.go b/token/generator_rand_error_test.go new file mode 100644 index 0000000..e5a891b --- /dev/null +++ b/token/generator_rand_error_test.go @@ -0,0 +1,41 @@ +package token + +import ( + "crypto/rand" + "errors" + "io" + "testing" +) + +// failingReader always errors — swapped in for crypto/rand.Reader to +// deterministically exercise the rand.Read failure path, which never +// fails in practice under normal conditions. +type failingReader struct{} + +func (failingReader) Read(p []byte) (int, error) { + return 0, errors.New("simulated entropy source failure") +} + +func TestCryptoRandTokenGenerator_New_PropagatesRandReadError(t *testing.T) { + // Regression test: New() used to swallow a rand.Read failure and + // return ("", nil) — an empty string treated as a valid token + // with no error to catch it. It must now return the real error. + original := rand.Reader + rand.Reader = failingReader{} + defer func() { rand.Reader = original }() + + g, err := NewCryptoRandTokenGenerator(32) + if err != nil { + t.Fatalf("unexpected error constructing generator: %v", err) + } + + tok, err := g.New() + if err == nil { + t.Fatal("expected New() to return an error when rand.Read fails, got nil") + } + if tok != "" { + t.Errorf("expected an empty token alongside the error, got %q", tok) + } +} + +var _ io.Reader = failingReader{} From 7eca8c71b831b3b65840efd125388952bbbc48ff Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Sun, 30 Aug 2026 06:14:11 +0000 Subject: [PATCH 03/21] fix: close login timing side-channel for nonexistent-email attempts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When GetByEmail found no user, Login returned immediately, skipping hasher.Compare entirely. A login attempt against a nonexistent email returned far faster than one against a real email with a wrong password (which pays bcrypt's cost) — a textbook user-enumeration side channel via response timing alone, independent of the error message (which was already identical either way). Fix: run a dummy hasher.Hash call on the nonexistent-user path so its timing profile matches a real wrong-password attempt. hasher.Hash and hasher.Compare run the same underlying bcrypt cost function, so this doesn't require a separately-maintained dummy hash. --- auth/login.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/auth/login.go b/auth/login.go index 9777268..a9e4dd9 100644 --- a/auth/login.go +++ b/auth/login.go @@ -49,6 +49,14 @@ func Login( user, err := users.GetByEmail(ctx, email) if err != nil { + // Still pay bcrypt's cost even though there's no hash to + // check against — hasher.Hash runs the same underlying cost + // function as hasher.Compare. Without this, a nonexistent- + // email response returns measurably faster than a wrong- + // password one, letting an attacker enumerate registered + // emails by timing alone even though the returned error is + // identical either way. + _, _ = hasher.Hash(password) recordLoginFailure(ctx, audit, log, "", callerIP, "no_such_user") return Tokens{}, ErrInvalidCredentials } From e9a5aeb47cf3675a7475885e2b6736b7f9b4584b Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Sun, 30 Aug 2026 06:14:30 +0000 Subject: [PATCH 04/21] test: add timing regression test for the login enumeration fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coarse smoke test (not a precision timing analysis) asserting the nonexistent-user path isn't dramatically faster than a real wrong-password attempt — enough to catch a future regression that removes the dummy hasher.Hash call. --- auth/login_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/auth/login_test.go b/auth/login_test.go index ecf8654..e771e6e 100644 --- a/auth/login_test.go +++ b/auth/login_test.go @@ -69,3 +69,42 @@ func TestLogin_NonexistentUserRejectedWithSameError(t *testing.T) { t.Errorf("expected ErrInvalidCredentials (same as wrong password), got %v", err) } } + +func TestLogin_NonexistentUserTimingMatchesWrongPassword(t *testing.T) { + // Regression test for the timing side-channel: before the fix, + // the nonexistent-user path returned before ever calling + // hasher.Compare, making it measurably faster than a real + // wrong-password attempt and letting an attacker enumerate + // registered emails by response time alone even though the + // returned error was already identical. A real cost-4 bcrypt + // hash still takes single-digit milliseconds, so both paths + // should land in the same rough band, not orders of magnitude + // apart. This is a coarse smoke test, not a precise timing + // analysis — its job is to catch a future regression that removes + // the dummy hasher.Hash call entirely, not to certify + // constant-time behavior. + users, sessions, audit, hasher, ids, refreshGen, jwtIssuer, limiter := newLoginTestDeps(t) + log := testLogger{} + ctx := context.Background() + + hash, _ := hasher.Hash("correct-password") + users.Create(ctx, storeUser("user-1", "proguy@example.com", hash)) + + start := time.Now() + Login(ctx, users, sessions, hasher, ids, refreshGen, jwtIssuer, limiter, audit, log, + "proguy@example.com", "wrong-password", "1.2.3.4", "test-agent", 5, time.Minute) + wrongPasswordDuration := time.Since(start) + + start = time.Now() + Login(ctx, users, sessions, hasher, ids, refreshGen, jwtIssuer, limiter, audit, log, + "nobody@example.com", "any-password", "1.2.3.4", "test-agent", 5, time.Minute) + nonexistentUserDuration := time.Since(start) + + // Nonexistent-user path should never be dramatically faster — + // allow a generous 2x margin either direction for test-runner + // noise, since this isn't a precision timing measurement. + ratio := float64(nonexistentUserDuration) / float64(wrongPasswordDuration) + if ratio < 0.5 { + t.Errorf("nonexistent-user login returned %v, wrong-password returned %v (ratio %.2f) — the dummy hash may not be running", nonexistentUserDuration, wrongPasswordDuration, ratio) + } +} From bfc9d89453aa16937de680154c5f67ed2296239f Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Sun, 30 Aug 2026 06:14:36 +0000 Subject: [PATCH 05/21] feat: add Encryptor interface and AES-256-GCM implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reversible symmetric encryption for secrets that must be recovered in plaintext later — unlike Hasher, which is deliberately one-way. Needed for TOTP secrets: the engine must decrypt a secret back to its raw value to validate a code against it, so hashing doesn't apply here. --- security/encryption.go | 90 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 security/encryption.go diff --git a/security/encryption.go b/security/encryption.go new file mode 100644 index 0000000..57e4d20 --- /dev/null +++ b/security/encryption.go @@ -0,0 +1,90 @@ +package security + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" +) + +var ( + ErrMissingEncryptionKey = errors.New("security: encryption key is required") + ErrCiphertextTooShort = errors.New("security: ciphertext too short to contain a nonce") +) + +// Encryptor defines reversible symmetric encryption of small secrets +// that must be recovered in plaintext later — unlike Hasher, which is +// deliberately one-way. A TOTP secret is the motivating case: the +// engine must decrypt it back to the raw value to validate a code +// against it, so hashing (as used for passwords/tokens) doesn't apply +// here. v2 ships one implementation: AESGCMEncryptor. +type Encryptor interface { + Encrypt(plaintext string) (string, error) + Decrypt(ciphertext string) (string, error) +} + +// AESGCMEncryptor is the v2 Encryptor implementation. +type AESGCMEncryptor struct { + key []byte // exactly 32 bytes, for AES-256 +} + +// NewAESGCMEncryptor derives a 32-byte AES-256 key from secret via +// SHA-256. Hashing here only normalizes an arbitrary-length input +// down to AES-256's required key size — it is not a KDF standing in +// for secret strength. secret should be configured with the same +// care as JWTSecret (long, random, out of source control), not a +// human-memorable passphrase. +func NewAESGCMEncryptor(secret string) (*AESGCMEncryptor, error) { + if secret == "" { + return nil, ErrMissingEncryptionKey + } + key := sha256.Sum256([]byte(secret)) + return &AESGCMEncryptor{key: key[:]}, nil +} + +func (e *AESGCMEncryptor) Encrypt(plaintext string) (string, error) { + block, err := aes.NewCipher(e.key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + // Nonce is prepended to the ciphertext — standard practice for + // AES-GCM, since the nonce isn't secret, only single-use. + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +func (e *AESGCMEncryptor) Decrypt(ciphertext string) (string, error) { + raw, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return "", err + } + block, err := aes.NewCipher(e.key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + if len(raw) < gcm.NonceSize() { + return "", ErrCiphertextTooShort + } + nonce, ct := raw[:gcm.NonceSize()], raw[gcm.NonceSize():] + plaintext, err := gcm.Open(nil, nonce, ct, nil) + if err != nil { + return "", err + } + return string(plaintext), nil +} + +var _ Encryptor = (*AESGCMEncryptor)(nil) From 08cdc12698078d9eba9da9a82ebad5a04a38bcb0 Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Sun, 30 Aug 2026 06:14:36 +0000 Subject: [PATCH 06/21] test: add Encryptor unit tests Round-trip, distinct nonce per call, wrong key fails to decrypt, empty key rejected at construction. --- security/encryption_test.go | 56 +++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 security/encryption_test.go diff --git a/security/encryption_test.go b/security/encryption_test.go new file mode 100644 index 0000000..8ca6786 --- /dev/null +++ b/security/encryption_test.go @@ -0,0 +1,56 @@ +package security + +import "testing" + +func TestAESGCMEncryptor_EncryptDecryptRoundTrip(t *testing.T) { + enc, err := NewAESGCMEncryptor("test-encryption-key") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + plaintext := "JBSWY3DPEHPK3PXP" // example base32 TOTP secret + ciphertext, err := enc.Encrypt(plaintext) + if err != nil { + t.Fatalf("encrypt failed: %v", err) + } + if ciphertext == plaintext { + t.Fatal("ciphertext must not equal the plaintext") + } + + decrypted, err := enc.Decrypt(ciphertext) + if err != nil { + t.Fatalf("decrypt failed: %v", err) + } + if decrypted != plaintext { + t.Errorf("expected %q, got %q", plaintext, decrypted) + } +} + +func TestAESGCMEncryptor_DifferentNoncePerCall(t *testing.T) { + // Two encryptions of the same plaintext must produce different + // ciphertexts (random nonce per call) — an attacker comparing two + // stored secrets should never be able to tell they're equal. + enc, _ := NewAESGCMEncryptor("test-encryption-key") + + a, _ := enc.Encrypt("same-secret") + b, _ := enc.Encrypt("same-secret") + if a == b { + t.Error("expected different ciphertexts for repeated encryption of the same plaintext") + } +} + +func TestAESGCMEncryptor_WrongKeyFailsToDecrypt(t *testing.T) { + encA, _ := NewAESGCMEncryptor("key-a") + encB, _ := NewAESGCMEncryptor("key-b") + + ciphertext, _ := encA.Encrypt("secret-value") + if _, err := encB.Decrypt(ciphertext); err == nil { + t.Error("expected decryption with the wrong key to fail") + } +} + +func TestNewAESGCMEncryptor_EmptyKeyRejected(t *testing.T) { + if _, err := NewAESGCMEncryptor(""); err != ErrMissingEncryptionKey { + t.Errorf("expected ErrMissingEncryptionKey, got %v", err) + } +} From cb146f3218e22ed500e741af26d6da9d444a18ec Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Sun, 30 Aug 2026 06:14:42 +0000 Subject: [PATCH 07/21] feat: add TOTPGenerator interface backed by pquerna/otp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps github.com/pquerna/otp rather than hand-rolling RFC 6238 against the stdlib the way Hasher/RateLimiter/IDGenerator do — TOTP has enough real edge cases (base32 padding, clock-skew windows, Google-Authenticator-compatible defaults) that a battle-tested implementation is worth the one dependency. Adds github.com/pquerna/otp and its transitive boombuler/barcode dependency (used internally by otp's package-level QR-image helper, which this engine never calls) to go.mod. Run 'go mod tidy' to populate go.sum. --- go.mod | 8 +++++++ security/totp.go | 59 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 security/totp.go diff --git a/go.mod b/go.mod index 3238a13..71c0da0 100644 --- a/go.mod +++ b/go.mod @@ -6,5 +6,13 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/lib/pq v1.12.3 + github.com/pquerna/otp v1.5.0 golang.org/x/crypto v0.54.0 ) + +// github.com/pquerna/otp pulls in boombuler/barcode transitively (used +// internally for its optional QR-image helper, which this engine +// never calls — TOTP QR rendering is a presentation concern that +// belongs in a consuming app, not the engine). Go still needs it to +// build the otp package itself. +require github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect diff --git a/security/totp.go b/security/totp.go new file mode 100644 index 0000000..952753b --- /dev/null +++ b/security/totp.go @@ -0,0 +1,59 @@ +package security + +import ( + "time" + + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" +) + +// TOTPGenerator defines TOTP (RFC 6238) secret generation and code +// validation. v2 ships one implementation, PquernaTOTPGenerator, +// wrapping github.com/pquerna/otp rather than hand-rolling RFC 6238 +// against the stdlib the way Hasher/RateLimiter/IDGenerator do — +// TOTP has enough real edge cases (base32 padding, clock-skew +// windows, Google-Authenticator-compatible defaults) that a +// battle-tested implementation is worth the one dependency. +type TOTPGenerator interface { + // NewSecret generates a fresh base32 secret for a new enrollment. + // issuer and accountName are purely presentational — they're + // encoded into the returned otpauth:// URL so an authenticator + // app can label the entry, and are never persisted by the engine. + // accountName should be the user's email. + NewSecret(issuer, accountName string) (secret string, otpauthURL string, err error) + // Validate checks code against secret at time t, allowing the + // standard ±1 step (30s) clock-skew tolerance. t is an explicit + // parameter (not time.Now() internally) so callers/tests can + // exercise skew behavior deterministically. + Validate(secret, code string, t time.Time) bool +} + +// PquernaTOTPGenerator is the v2 TOTPGenerator implementation. +type PquernaTOTPGenerator struct{} + +func NewPquernaTOTPGenerator() *PquernaTOTPGenerator { + return &PquernaTOTPGenerator{} +} + +func (g *PquernaTOTPGenerator) NewSecret(issuer, accountName string) (string, string, error) { + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: issuer, + AccountName: accountName, + }) + if err != nil { + return "", "", err + } + return key.Secret(), key.URL(), nil +} + +func (g *PquernaTOTPGenerator) Validate(secret, code string, t time.Time) bool { + valid, _ := totp.ValidateCustom(code, secret, t, totp.ValidateOpts{ + Period: 30, + Skew: 1, + Digits: otp.DigitsSix, + Algorithm: otp.AlgorithmSHA1, // Google-Authenticator-compatible; see pquerna/otp#55 + }) + return valid +} + +var _ TOTPGenerator = (*PquernaTOTPGenerator)(nil) From 87434fec140a528802989254643e1ecd895fc847 Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Sun, 30 Aug 2026 06:14:42 +0000 Subject: [PATCH 08/21] test: add TOTPGenerator unit tests Secret/URL generation, correct-code acceptance, wrong-code rejection, expired-code rejection outside the skew window. --- security/totp_test.go | 62 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 security/totp_test.go diff --git a/security/totp_test.go b/security/totp_test.go new file mode 100644 index 0000000..eb8d4e0 --- /dev/null +++ b/security/totp_test.go @@ -0,0 +1,62 @@ +package security + +import ( + "testing" + "time" + + "github.com/pquerna/otp/totp" +) + +func TestPquernaTOTPGenerator_NewSecretReturnsUsableSecretAndURL(t *testing.T) { + gen := NewPquernaTOTPGenerator() + + secret, url, err := gen.NewSecret("CrydenSync", "user@example.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if secret == "" { + t.Error("expected a non-empty secret") + } + if url == "" { + t.Error("expected a non-empty otpauth:// URL") + } +} + +func TestPquernaTOTPGenerator_ValidateAcceptsCorrectCode(t *testing.T) { + gen := NewPquernaTOTPGenerator() + secret, _, err := gen.NewSecret("CrydenSync", "user@example.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + now := time.Now() + code, err := totp.GenerateCode(secret, now) + if err != nil { + t.Fatalf("failed to generate a real code: %v", err) + } + + if !gen.Validate(secret, code, now) { + t.Error("expected a correctly generated code to validate") + } +} + +func TestPquernaTOTPGenerator_ValidateRejectsWrongCode(t *testing.T) { + gen := NewPquernaTOTPGenerator() + secret, _, _ := gen.NewSecret("CrydenSync", "user@example.com") + + if gen.Validate(secret, "000000", time.Now()) { + t.Error("expected an arbitrary wrong code to be rejected (astronomically unlikely to collide)") + } +} + +func TestPquernaTOTPGenerator_ValidateRejectsExpiredCode(t *testing.T) { + gen := NewPquernaTOTPGenerator() + secret, _, _ := gen.NewSecret("CrydenSync", "user@example.com") + + past := time.Now().Add(-10 * time.Minute) + code, _ := totp.GenerateCode(secret, past) + + if gen.Validate(secret, code, time.Now()) { + t.Error("expected a code from 10 minutes ago to be rejected — well outside the ±1 step skew window") + } +} From adf1f27b935793043071ade2acfa21aeebe3b404 Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Sun, 30 Aug 2026 06:14:50 +0000 Subject: [PATCH 09/21] feat: add TOTPStore interface and TOTPSecret type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One secret per user. EncryptedSecret is encrypted at rest, never hashed (validating a code requires recovering the original secret). ConfirmedAt is nil until the user proves possession with one valid code — an unconfirmed secret must never gate login. Also adds totp_enabled/totp_disabled/totp_challenge_failed audit event types. --- store/interfaces.go | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/store/interfaces.go b/store/interfaces.go index 278c468..d77bd77 100644 --- a/store/interfaces.go +++ b/store/interfaces.go @@ -120,6 +120,9 @@ const ( EventEmailChanged AuditEventType = "email_changed" EventAccountDeleted AuditEventType = "account_deleted" EventOAuthLinked AuditEventType = "oauth_linked" + EventTOTPEnabled AuditEventType = "totp_enabled" + EventTOTPDisabled AuditEventType = "totp_disabled" + EventTOTPChallengeFailed AuditEventType = "totp_challenge_failed" ) // AuditEvent is a single security-relevant, queryable record. @@ -203,3 +206,30 @@ type OAuthStore interface { ListByUser(ctx context.Context, userID string) ([]OAuthIdentity, error) Unlink(ctx context.Context, identityID string) error } + +// TOTPSecret represents a user's enrolled TOTP (2FA) secret. +// EncryptedSecret is encrypted at rest via security.Encryptor — never +// hashed, since validating a code requires recovering the original +// secret, unlike passwords/tokens. ConfirmedAt is nil until the user +// proves possession with one valid code; an unconfirmed secret must +// never gate a login (see auth.ConfirmTOTP). +type TOTPSecret struct { + UserID string + EncryptedSecret string + ConfirmedAt *time.Time + CreatedAt time.Time +} + +// TOTPStore defines persistence for TOTP secrets. One secret per +// user — re-enrolling replaces the existing row rather than creating +// a second one, and always resets ConfirmedAt to nil, so restarting +// enrollment can never leave a stale confirmed secret active +// alongside a new unconfirmed one. +type TOTPStore interface { + Upsert(ctx context.Context, secret TOTPSecret) error + GetByUserID(ctx context.Context, userID string) (TOTPSecret, error) + // Confirm marks the existing secret confirmed. Errors with + // ErrNotFound if no secret is pending for userID. + Confirm(ctx context.Context, userID string) error + Delete(ctx context.Context, userID string) error +} From c182f7b1ad1f25f389cb763f657a22e4305ca523 Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Sun, 30 Aug 2026 06:14:50 +0000 Subject: [PATCH 10/21] feat: add in-memory TOTPStore implementation For tests and local experimentation only, matching the existing in-memory store conventions (not a supported production backend). --- store/memory/totp_store.go | 65 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 store/memory/totp_store.go diff --git a/store/memory/totp_store.go b/store/memory/totp_store.go new file mode 100644 index 0000000..041a23a --- /dev/null +++ b/store/memory/totp_store.go @@ -0,0 +1,65 @@ +package memory + +import ( + "context" + "sync" + "time" + + "github.com/crydensync/cryden/v2/store" +) + +// TOTPStore is an in-memory store.TOTPStore implementation for tests +// and local experimentation only — not a supported production +// backend. The Postgres implementation is authoritative for prod. +type TOTPStore struct { + mu sync.Mutex + byID map[string]store.TOTPSecret +} + +func NewTOTPStore() *TOTPStore { + return &TOTPStore{byID: make(map[string]store.TOTPSecret)} +} + +func (s *TOTPStore) Upsert(ctx context.Context, secret store.TOTPSecret) error { + s.mu.Lock() + defer s.mu.Unlock() + secret.CreatedAt = time.Now() + secret.ConfirmedAt = nil + s.byID[secret.UserID] = secret + return nil +} + +func (s *TOTPStore) GetByUserID(ctx context.Context, userID string) (store.TOTPSecret, error) { + s.mu.Lock() + defer s.mu.Unlock() + secret, ok := s.byID[userID] + if !ok { + return store.TOTPSecret{}, store.ErrNotFound + } + return secret, nil +} + +func (s *TOTPStore) Confirm(ctx context.Context, userID string) error { + s.mu.Lock() + defer s.mu.Unlock() + secret, ok := s.byID[userID] + if !ok { + return store.ErrNotFound + } + now := time.Now() + secret.ConfirmedAt = &now + s.byID[userID] = secret + return nil +} + +func (s *TOTPStore) Delete(ctx context.Context, userID string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.byID[userID]; !ok { + return store.ErrNotFound + } + delete(s.byID, userID) + return nil +} + +var _ store.TOTPStore = (*TOTPStore)(nil) From 30435ac2204f141ddf7a59e6cef8aeecb8ea657f Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Sun, 30 Aug 2026 06:14:50 +0000 Subject: [PATCH 11/21] feat: add Postgres TOTPStore implementation The v2 production TOTPStore backend. Upsert always resets confirmed_at to NULL on conflict, so restarting enrollment can never leave a stale confirmed secret active alongside a new unconfirmed one. --- store/postgres/totp_store.go | 67 ++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 store/postgres/totp_store.go diff --git a/store/postgres/totp_store.go b/store/postgres/totp_store.go new file mode 100644 index 0000000..e063d13 --- /dev/null +++ b/store/postgres/totp_store.go @@ -0,0 +1,67 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + + "github.com/crydensync/cryden/v2/store" +) + +// TOTPStore is the v2 production store.TOTPStore implementation. +type TOTPStore struct { + db *sql.DB +} + +func NewTOTPStore(db *sql.DB) *TOTPStore { + return &TOTPStore{db: db} +} + +func (s *TOTPStore) Upsert(ctx context.Context, secret store.TOTPSecret) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO totp_secrets (user_id, encrypted_secret, confirmed_at) + VALUES ($1, $2, NULL) + ON CONFLICT (user_id) DO UPDATE + SET encrypted_secret = EXCLUDED.encrypted_secret, confirmed_at = NULL + `, secret.UserID, secret.EncryptedSecret) + return err +} + +func (s *TOTPStore) GetByUserID(ctx context.Context, userID string) (store.TOTPSecret, error) { + var secret store.TOTPSecret + var confirmedAt sql.NullTime + err := s.db.QueryRowContext(ctx, ` + SELECT user_id, encrypted_secret, confirmed_at, created_at + FROM totp_secrets WHERE user_id = $1 + `, userID).Scan(&secret.UserID, &secret.EncryptedSecret, &confirmedAt, &secret.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + return store.TOTPSecret{}, store.ErrNotFound + } + if err != nil { + return store.TOTPSecret{}, err + } + if confirmedAt.Valid { + secret.ConfirmedAt = &confirmedAt.Time + } + return secret, nil +} + +func (s *TOTPStore) Confirm(ctx context.Context, userID string) error { + result, err := s.db.ExecContext(ctx, ` + UPDATE totp_secrets SET confirmed_at = now() WHERE user_id = $1 + `, userID) + if err != nil { + return err + } + return checkRowsAffected(result) +} + +func (s *TOTPStore) Delete(ctx context.Context, userID string) error { + result, err := s.db.ExecContext(ctx, `DELETE FROM totp_secrets WHERE user_id = $1`, userID) + if err != nil { + return err + } + return checkRowsAffected(result) +} + +var _ store.TOTPStore = (*TOTPStore)(nil) From 7ce93d3068e80b22402b988c93dab5c1677cb350 Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Sun, 30 Aug 2026 06:14:50 +0000 Subject: [PATCH 12/21] feat: add totp_secrets table migration --- .../postgres/migrations/0003_totp_secrets.down.sql | 3 +++ store/postgres/migrations/0003_totp_secrets.up.sql | 13 +++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 store/postgres/migrations/0003_totp_secrets.down.sql create mode 100644 store/postgres/migrations/0003_totp_secrets.up.sql diff --git a/store/postgres/migrations/0003_totp_secrets.down.sql b/store/postgres/migrations/0003_totp_secrets.down.sql new file mode 100644 index 0000000..b605e3a --- /dev/null +++ b/store/postgres/migrations/0003_totp_secrets.down.sql @@ -0,0 +1,3 @@ +-- 0003_totp_secrets.down.sql + +DROP TABLE totp_secrets; diff --git a/store/postgres/migrations/0003_totp_secrets.up.sql b/store/postgres/migrations/0003_totp_secrets.up.sql new file mode 100644 index 0000000..ce10fff --- /dev/null +++ b/store/postgres/migrations/0003_totp_secrets.up.sql @@ -0,0 +1,13 @@ +-- 0003_totp_secrets.up.sql + +CREATE TABLE totp_secrets ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + -- Encrypted (AES-256-GCM), never plaintext, never hashed — the + -- engine must recover the original secret to validate a code + -- against it, so hashing (as used for passwords) doesn't apply. + encrypted_secret TEXT NOT NULL, + -- NULL until the user proves possession with one valid code. + -- An unconfirmed secret must never gate a login. + confirmed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); From 83ba8db3f9999c409bbc86e8c6bf71e0b3dfd9ef Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Sun, 30 Aug 2026 06:14:55 +0000 Subject: [PATCH 13/21] feat: add MFAPendingIssuer for second-factor login handoff Short-lived (5 min, fixed, not configurable), stateless token proving a caller already presented a correct password for the embedded userID and is now expected to complete login with a second factor. Signed with the same secret as JWTIssuer but distinguished by a dedicated 'typ' claim, checked on Verify, specifically to prevent a real access token ever being accepted in its place. --- token/mfa_pending.go | 77 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 token/mfa_pending.go diff --git a/token/mfa_pending.go b/token/mfa_pending.go new file mode 100644 index 0000000..3148f9e --- /dev/null +++ b/token/mfa_pending.go @@ -0,0 +1,77 @@ +package token + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var ErrInvalidPendingToken = errors.New("token: pending MFA token invalid or expired") + +// mfaPendingTTL is fixed, not configurable via Config. A "password +// verified, awaiting second factor" window should always be short — +// TOTP codes themselves rotate every 30s — so making this a tuning +// knob would just invite a deployment to widen a narrow race into a +// standing credential. +const mfaPendingTTL = 5 * time.Minute + +// MFAPendingIssuer issues and verifies short-lived, stateless tokens +// proving a caller already presented a correct password for the +// embedded userID and is now expected to complete login with a second +// factor. Never treat one of these as equivalent to a real access +// token — Verify checks a dedicated "typ" claim specifically to +// prevent that confusion, even though both token types are signed +// with the same secret. +type MFAPendingIssuer struct { + secret []byte +} + +// NewMFAPendingIssuer constructs an MFAPendingIssuer. secret must be +// non-empty — reuses Config.JWTSecret, same as JWTIssuer; there is no +// separate secret to configure for this token type. +func NewMFAPendingIssuer(secret string) (*MFAPendingIssuer, error) { + if secret == "" { + return nil, ErrMissingJWTSecret + } + return &MFAPendingIssuer{secret: []byte(secret)}, nil +} + +type mfaPendingClaims struct { + Typ string `json:"typ"` + jwt.RegisteredClaims +} + +// Issue creates a signed pending-login token for userID, expiring +// after mfaPendingTTL. +func (m *MFAPendingIssuer) Issue(userID string) (string, error) { + now := time.Now() + claims := mfaPendingClaims{ + Typ: "mfa_pending", + RegisteredClaims: jwt.RegisteredClaims{ + Subject: userID, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(mfaPendingTTL)), + }, + } + t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return t.SignedString(m.secret) +} + +// Verify checks the token's signature, expiry, and type claim, +// returning the embedded user ID if valid. +func (m *MFAPendingIssuer) Verify(tokenStr string) (string, error) { + claims := &mfaPendingClaims{} + parsed, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) { + // Reject any token not signed with the algorithm we issue — + // prevents algorithm-confusion attacks (e.g. "alg: none"). + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, ErrInvalidPendingToken + } + return m.secret, nil + }) + if err != nil || !parsed.Valid || claims.Typ != "mfa_pending" { + return "", ErrInvalidPendingToken + } + return claims.Subject, nil +} From f909e86d5238c002fc03a66c439177e6195e2ee9 Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Sun, 30 Aug 2026 06:15:05 +0000 Subject: [PATCH 14/21] feat: add TOTP enrollment, confirmation, and disable flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EnrollTOTP: generates a secret, encrypts it at rest, returns the otpauth:// URL. Does not gate login yet. Rejects re-enrollment once a secret is already confirmed. - ConfirmTOTP: activates a pending secret once the user proves possession with one valid code. A secret that's never confirmed can never gate login — prevents an interrupted enrollment (browser closed before scanning the QR code) from locking the user out. - DisableTOTP: requires the current password as re-confirmation, same reasoning as ChangePassword/DeleteAccount — a stolen access token alone should never be enough to weaken an account's own auth requirements. - CompleteLoginWithTOTP: verifies a pending token, checks the code, and issues real tokens on success. Adds *ErrTOTPRequired (struct type, retrievable via errors.As, same pattern as *ErrOAuthEmailConflict), ErrTOTPNotEnabled, ErrTOTPAlreadyEnabled, ErrInvalidTOTPCode, and ErrInvalidPendingLogin. --- auth/errors.go | 35 +++++++++ auth/mfa.go | 199 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 auth/mfa.go diff --git a/auth/errors.go b/auth/errors.go index f5a8db8..2b4a81c 100644 --- a/auth/errors.go +++ b/auth/errors.go @@ -44,3 +44,38 @@ func (e *ErrOAuthEmailConflict) Error() string { // link to a new account — that would let one user hijack a provider // identity another user already claimed. var ErrOAuthIdentityAlreadyLinked = errors.New("auth: this provider account is already linked to a different user") + +// ErrTOTPRequired is returned by Login when the account has a +// confirmed TOTP secret — a correct password is no longer sufficient +// on its own. PendingToken must be presented to CompleteLoginWithTOTP +// together with the user's current code. It is NOT a valid access or +// refresh token and proves nothing beyond "this caller already +// supplied a correct password for this user." Deliberately a struct +// type (not a plain sentinel), same reasoning as +// ErrOAuthEmailConflict — callers use errors.As to retrieve it. +type ErrTOTPRequired struct { + PendingToken string +} + +func (e *ErrTOTPRequired) Error() string { + return "auth: TOTP code required to complete login" +} + +var ( + // ErrTOTPNotEnabled is returned when a caller acts as though an + // account has TOTP enabled (e.g. CompleteLoginWithTOTP) but it + // doesn't, or its enrollment was never confirmed. + ErrTOTPNotEnabled = errors.New("auth: TOTP is not enabled for this account") + ErrTOTPAlreadyEnabled = errors.New("auth: TOTP is already enabled for this account") + // ErrInvalidTOTPCode covers both a wrong code and an expired + // pending-login token that failed at the code-check step — + // deliberately not differentiated further than that, same + // enumeration-avoidance reasoning as ErrInvalidCredentials. + ErrInvalidTOTPCode = errors.New("auth: invalid or expired TOTP code") + // ErrInvalidPendingLogin is returned by CompleteLoginWithTOTP when + // pendingToken itself fails verification (expired, tampered, or + // not a pending-login token at all) — distinct from + // ErrInvalidTOTPCode, which covers a wrong code against an + // otherwise-valid pending login. + ErrInvalidPendingLogin = errors.New("auth: login session expired or invalid, please log in again") +) diff --git a/auth/mfa.go b/auth/mfa.go new file mode 100644 index 0000000..0dfc84a --- /dev/null +++ b/auth/mfa.go @@ -0,0 +1,199 @@ +package auth + +import ( + "context" + "time" + + "github.com/crydensync/cryden/v2/logger" + "github.com/crydensync/cryden/v2/security" + "github.com/crydensync/cryden/v2/store" + "github.com/crydensync/cryden/v2/token" +) + +// EnrollTOTP begins TOTP enrollment for an already-authenticated user: +// generates a new secret, encrypts it at rest, and returns the +// otpauth:// URL for the caller to render as a QR code. The secret +// does NOT gate login yet — ConfirmTOTP must be called with a valid +// code first, proving the user actually captured the secret in their +// authenticator app. Re-enrolling an account that already has a +// confirmed secret is rejected; DisableTOTP must be called first. +func EnrollTOTP( + ctx context.Context, + users store.UserStore, + totpStore store.TOTPStore, + totpGen security.TOTPGenerator, + enc security.Encryptor, + issuerName string, + userID string, +) (otpauthURL string, err error) { + user, err := users.GetByID(ctx, userID) + if err != nil { + return "", err + } + + if existing, getErr := totpStore.GetByUserID(ctx, userID); getErr == nil && existing.ConfirmedAt != nil { + return "", ErrTOTPAlreadyEnabled + } + + secret, url, err := totpGen.NewSecret(issuerName, user.Email) + if err != nil { + return "", err + } + + encryptedSecret, err := enc.Encrypt(secret) + if err != nil { + return "", err + } + + if err := totpStore.Upsert(ctx, store.TOTPSecret{ + UserID: userID, + EncryptedSecret: encryptedSecret, + }); err != nil { + return "", err + } + + return url, nil +} + +// ConfirmTOTP activates a pending TOTP enrollment once the user proves +// they've correctly captured the secret by submitting one valid code. +// A secret written by EnrollTOTP but never confirmed can never gate a +// login — this prevents an enrollment interrupted mid-flow (e.g. the +// browser closed before the QR code was scanned) from silently +// locking the user out on their next login. +func ConfirmTOTP( + ctx context.Context, + totpStore store.TOTPStore, + totpGen security.TOTPGenerator, + enc security.Encryptor, + audit store.AuditStore, + log logger.Logger, + userID string, + code string, +) error { + secretRec, err := totpStore.GetByUserID(ctx, userID) + if err != nil { + return err + } + if secretRec.ConfirmedAt != nil { + return ErrTOTPAlreadyEnabled + } + + plainSecret, err := enc.Decrypt(secretRec.EncryptedSecret) + if err != nil { + return err + } + + if !totpGen.Validate(plainSecret, code, time.Now()) { + return ErrInvalidTOTPCode + } + + if err := totpStore.Confirm(ctx, userID); err != nil { + return err + } + + if err := audit.Record(ctx, store.AuditEvent{ + Type: store.EventTOTPEnabled, + UserID: userID, + }); err != nil { + log.Error("confirm totp: audit record failed", map[string]string{"error": err.Error(), "user_id": userID}) + } + + log.Info("totp enabled", map[string]string{"user_id": userID}) + return nil +} + +// DisableTOTP removes a user's TOTP secret. Requires the current +// password as re-confirmation — same reasoning as +// ChangePassword/DeleteAccount: a stolen access token alone should +// never be sufficient to weaken an account's own auth requirements. +func DisableTOTP( + ctx context.Context, + users store.UserStore, + totpStore store.TOTPStore, + hasher security.Hasher, + audit store.AuditStore, + log logger.Logger, + userID string, + currentPassword string, +) error { + user, err := users.GetByID(ctx, userID) + if err != nil { + return err + } + + if err := hasher.Compare(user.PasswordHash, currentPassword); err != nil { + log.Warn("disable totp: password mismatch", map[string]string{"user_id": userID}) + return ErrInvalidCredentials + } + + if err := totpStore.Delete(ctx, userID); err != nil { + return err + } + + if err := audit.Record(ctx, store.AuditEvent{ + Type: store.EventTOTPDisabled, + UserID: userID, + }); err != nil { + log.Error("disable totp: audit record failed", map[string]string{"error": err.Error(), "user_id": userID}) + } + + log.Info("totp disabled", map[string]string{"user_id": userID}) + return nil +} + +// CompleteLoginWithTOTP finishes a login that Login paused with +// *ErrTOTPRequired. pendingToken proves a correct password was +// already presented for the user encoded inside it; code is the +// current value from the user's authenticator app. +func CompleteLoginWithTOTP( + ctx context.Context, + users store.UserStore, + sessions store.SessionStore, + totpStore store.TOTPStore, + totpGen security.TOTPGenerator, + enc security.Encryptor, + ids security.IDGenerator, + refreshGen token.TokenGenerator, + jwtIssuer *token.JWTIssuer, + pendingIssuer *token.MFAPendingIssuer, + audit store.AuditStore, + log logger.Logger, + pendingToken string, + code string, + callerIP string, + userAgent string, +) (Tokens, error) { + userID, err := pendingIssuer.Verify(pendingToken) + if err != nil { + return Tokens{}, ErrInvalidPendingLogin + } + + user, err := users.GetByID(ctx, userID) + if err != nil { + return Tokens{}, err + } + + secretRec, err := totpStore.GetByUserID(ctx, userID) + if err != nil || secretRec.ConfirmedAt == nil { + return Tokens{}, ErrTOTPNotEnabled + } + + plainSecret, err := enc.Decrypt(secretRec.EncryptedSecret) + if err != nil { + return Tokens{}, err + } + + if !totpGen.Validate(plainSecret, code, time.Now()) { + if auditErr := audit.Record(ctx, store.AuditEvent{ + Type: store.EventTOTPChallengeFailed, + UserID: userID, + IP: callerIP, + }); auditErr != nil { + log.Error("complete totp login: audit record failed", map[string]string{"error": auditErr.Error()}) + } + return Tokens{}, ErrInvalidTOTPCode + } + + return finishLogin(ctx, sessions, ids, refreshGen, jwtIssuer, audit, log, user, callerIP, userAgent, "totp") +} From 2135756e7f54924f68de4c756cd7f70fb87e5c9a Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Sun, 30 Aug 2026 06:15:10 +0000 Subject: [PATCH 15/21] test: add unit tests for TOTP enrollment/confirm/disable Covers: enroll stores an unconfirmed secret, re-enrollment rejected once confirmed, confirm rejects a wrong code without confirming, confirm accepts a correct code, disable requires the correct password and leaves the secret untouched on a rejected attempt. --- auth/mfa_test.go | 158 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 auth/mfa_test.go diff --git a/auth/mfa_test.go b/auth/mfa_test.go new file mode 100644 index 0000000..e1eb933 --- /dev/null +++ b/auth/mfa_test.go @@ -0,0 +1,158 @@ +package auth + +import ( + "context" + "testing" + "time" + + "github.com/crydensync/cryden/v2/security" + "github.com/crydensync/cryden/v2/store" + "github.com/crydensync/cryden/v2/store/memory" + "github.com/pquerna/otp/totp" +) + +func newMFATestDeps(t *testing.T) (*memory.UserStore, *memory.TOTPStore, *memory.AuditStore, security.Hasher, security.TOTPGenerator, security.Encryptor) { + t.Helper() + users := memory.NewUserStore() + totpStore := memory.NewTOTPStore() + audit := memory.NewAuditStore() + hasher, _ := security.NewBcryptHasher(4) + totpGen := security.NewPquernaTOTPGenerator() + enc, _ := security.NewAESGCMEncryptor("test-encryption-key") + return users, totpStore, audit, hasher, totpGen, enc +} + +func TestEnrollTOTP_ReturnsURLAndStoresUnconfirmedSecret(t *testing.T) { + users, totpStore, _, hasher, totpGen, enc := newMFATestDeps(t) + ctx := context.Background() + + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + + url, err := EnrollTOTP(ctx, users, totpStore, totpGen, enc, "CrydenSync", "user-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if url == "" { + t.Error("expected a non-empty otpauth:// URL") + } + + secretRec, err := totpStore.GetByUserID(ctx, "user-1") + if err != nil { + t.Fatalf("expected a stored secret record: %v", err) + } + if secretRec.ConfirmedAt != nil { + t.Error("expected a freshly enrolled secret to be unconfirmed") + } + if secretRec.EncryptedSecret == "" { + t.Error("expected the stored secret to be non-empty") + } +} + +func TestEnrollTOTP_RejectsReenrollmentWhenAlreadyConfirmed(t *testing.T) { + users, totpStore, audit, hasher, totpGen, enc := newMFATestDeps(t) + log := testLogger{} + ctx := context.Background() + + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + + if _, err := EnrollTOTP(ctx, users, totpStore, totpGen, enc, "CrydenSync", "user-1"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + code := realCodeFromURL(t, totpGen, enc, totpStore, ctx, "user-1") + if err := ConfirmTOTP(ctx, totpStore, totpGen, enc, audit, log, "user-1", code); err != nil { + t.Fatalf("unexpected error confirming: %v", err) + } + + if _, err := EnrollTOTP(ctx, users, totpStore, totpGen, enc, "CrydenSync", "user-1"); err != ErrTOTPAlreadyEnabled { + t.Errorf("expected ErrTOTPAlreadyEnabled, got %v", err) + } +} + +func TestConfirmTOTP_RejectsWrongCode(t *testing.T) { + users, totpStore, audit, hasher, totpGen, enc := newMFATestDeps(t) + log := testLogger{} + ctx := context.Background() + + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + EnrollTOTP(ctx, users, totpStore, totpGen, enc, "CrydenSync", "user-1") + + if err := ConfirmTOTP(ctx, totpStore, totpGen, enc, audit, log, "user-1", "000000"); err != ErrInvalidTOTPCode { + t.Errorf("expected ErrInvalidTOTPCode, got %v", err) + } + + // A rejected confirmation must leave the secret unconfirmed — + // login must still work without a second factor. + secretRec, _ := totpStore.GetByUserID(ctx, "user-1") + if secretRec.ConfirmedAt != nil { + t.Error("expected secret to remain unconfirmed after a failed confirmation attempt") + } +} + +func TestConfirmTOTP_AcceptsCorrectCode(t *testing.T) { + users, totpStore, audit, hasher, totpGen, enc := newMFATestDeps(t) + log := testLogger{} + ctx := context.Background() + + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + EnrollTOTP(ctx, users, totpStore, totpGen, enc, "CrydenSync", "user-1") + + code := realCodeFromURL(t, totpGen, enc, totpStore, ctx, "user-1") + if err := ConfirmTOTP(ctx, totpStore, totpGen, enc, audit, log, "user-1", code); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + secretRec, _ := totpStore.GetByUserID(ctx, "user-1") + if secretRec.ConfirmedAt == nil { + t.Error("expected secret to be confirmed after a correct code") + } +} + +func TestDisableTOTP_RequiresCorrectPassword(t *testing.T) { + users, totpStore, audit, hasher, totpGen, enc := newMFATestDeps(t) + log := testLogger{} + ctx := context.Background() + + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + EnrollTOTP(ctx, users, totpStore, totpGen, enc, "CrydenSync", "user-1") + code := realCodeFromURL(t, totpGen, enc, totpStore, ctx, "user-1") + ConfirmTOTP(ctx, totpStore, totpGen, enc, audit, log, "user-1", code) + + if err := DisableTOTP(ctx, users, totpStore, hasher, audit, log, "user-1", "wrong-password"); err != ErrInvalidCredentials { + t.Errorf("expected ErrInvalidCredentials for wrong password, got %v", err) + } + if _, err := totpStore.GetByUserID(ctx, "user-1"); err != nil { + t.Error("expected secret to remain after a rejected disable attempt") + } + + if err := DisableTOTP(ctx, users, totpStore, hasher, audit, log, "user-1", "Tr0ubl3-Fr33!2026"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := totpStore.GetByUserID(ctx, "user-1"); err != store.ErrNotFound { + t.Error("expected secret to be deleted after a successful disable") + } +} + +// realCodeFromURL decrypts the stored secret and generates a real, +// currently valid code for it — test-only helper standing in for what +// a real authenticator app would produce during enrollment. +func realCodeFromURL(t *testing.T, totpGen security.TOTPGenerator, enc security.Encryptor, totpStore *memory.TOTPStore, ctx context.Context, userID string) string { + t.Helper() + secretRec, err := totpStore.GetByUserID(ctx, userID) + if err != nil { + t.Fatalf("failed to fetch secret: %v", err) + } + plainSecret, err := enc.Decrypt(secretRec.EncryptedSecret) + if err != nil { + t.Fatalf("failed to decrypt secret: %v", err) + } + code, err := totp.GenerateCode(plainSecret, time.Now()) + if err != nil { + t.Fatalf("failed to generate real code: %v", err) + } + return code +} From 2584a2a1c0f0d5daef133bcc057b4007d9c88360 Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Sun, 30 Aug 2026 06:15:18 +0000 Subject: [PATCH 16/21] feat: pause Login with ErrTOTPRequired for accounts with 2FA enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Login takes two new params (totpStore, pendingIssuer), both nil-safe — an Engine built without Config.TOTP passes nil for both and Login behaves exactly as before. After password verification, if the account has a confirmed TOTP secret, Login now issues a pending token and returns *ErrTOTPRequired instead of tokens. Extracts the post-verification tail (session creation, access token issuance, audit record) into a shared finishLogin helper, used by both Login (password-only path) and CompleteLoginWithTOTP (second-factor path), so a completed login produces an identical session regardless of which path got it there. This changes auth.Login's internal signature, not the public facade — cryden.Login(ctx, e, email, password, callerIP, userAgent) is unchanged; auth is documented as implementation detail, imported only by the top-level cryden package. Updates existing lockout_test.go/login_test.go call sites to pass nil, nil for the two new params. --- auth/lockout_test.go | 14 +++++------ auth/login.go | 56 +++++++++++++++++++++++++++++++++++++++++--- auth/login_test.go | 12 ++++++---- 3 files changed, 67 insertions(+), 15 deletions(-) diff --git a/auth/lockout_test.go b/auth/lockout_test.go index 46cadde..e62eb39 100644 --- a/auth/lockout_test.go +++ b/auth/lockout_test.go @@ -16,7 +16,7 @@ func TestLogin_LocksAccountAfterThreshold(t *testing.T) { threshold := 3 for i := 0; i < threshold; i++ { - _, err := Login(ctx, users, sessions, hasher, ids, refreshGen, jwtIssuer, limiter, audit, log, + _, err := Login(ctx, users, sessions, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "wrong-password", "1.2.3.4", "test-agent", threshold, time.Minute) if err != ErrInvalidCredentials { t.Fatalf("attempt %d: expected ErrInvalidCredentials, got %v", i+1, err) @@ -26,7 +26,7 @@ func TestLogin_LocksAccountAfterThreshold(t *testing.T) { // One more attempt, even with the CORRECT password, must now be // rejected as locked — the lock isn't just "N more wrong guesses // fail," it blocks everything including a legitimate login. - _, err := Login(ctx, users, sessions, hasher, ids, refreshGen, jwtIssuer, limiter, audit, log, + _, err := Login(ctx, users, sessions, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "correct-password", "1.2.3.4", "test-agent", threshold, time.Minute) if err != ErrAccountLocked { t.Errorf("expected ErrAccountLocked, got %v", err) @@ -44,12 +44,12 @@ func TestLogin_SuccessfulLoginResetsFailedAttempts(t *testing.T) { threshold := 5 // Two failed attempts, below threshold. for i := 0; i < 2; i++ { - Login(ctx, users, sessions, hasher, ids, refreshGen, jwtIssuer, limiter, audit, log, + Login(ctx, users, sessions, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "wrong-password", "1.2.3.4", "test-agent", threshold, time.Minute) } // A successful login should reset the counter. - _, err := Login(ctx, users, sessions, hasher, ids, refreshGen, jwtIssuer, limiter, audit, log, + _, err := Login(ctx, users, sessions, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "correct-password", "1.2.3.4", "test-agent", threshold, time.Minute) if err != nil { t.Fatalf("expected successful login, got %v", err) @@ -72,11 +72,11 @@ func TestLogin_LockExpiresAfterDuration(t *testing.T) { threshold := 1 shortLock := 10 * time.Millisecond - Login(ctx, users, sessions, hasher, ids, refreshGen, jwtIssuer, limiter, audit, log, + Login(ctx, users, sessions, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "wrong-password", "1.2.3.4", "test-agent", threshold, shortLock) // Immediately after: locked. - _, err := Login(ctx, users, sessions, hasher, ids, refreshGen, jwtIssuer, limiter, audit, log, + _, err := Login(ctx, users, sessions, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "correct-password", "1.2.3.4", "test-agent", threshold, shortLock) if err != ErrAccountLocked { t.Fatalf("expected ErrAccountLocked immediately after lock, got %v", err) @@ -85,7 +85,7 @@ func TestLogin_LockExpiresAfterDuration(t *testing.T) { time.Sleep(20 * time.Millisecond) // After the lock duration passes, login should succeed again. - _, err = Login(ctx, users, sessions, hasher, ids, refreshGen, jwtIssuer, limiter, audit, log, + _, err = Login(ctx, users, sessions, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "correct-password", "1.2.3.4", "test-agent", threshold, shortLock) if err != nil { t.Errorf("expected login to succeed after lock expiry, got %v", err) diff --git a/auth/login.go b/auth/login.go index a9e4dd9..0e96862 100644 --- a/auth/login.go +++ b/auth/login.go @@ -15,6 +15,12 @@ import ( // token pair). callerIP and userAgent are required, caller-supplied — // never inferred inside the engine. // +// totpStore and pendingIssuer are optional (nil if Config.TOTP isn't +// set). If the account has a confirmed TOTP secret, Login does not +// issue tokens directly — it returns *ErrTOTPRequired carrying a +// short-lived pending token; the caller must then call +// CompleteLoginWithTOTP with that token plus a code. +// // lockoutThreshold and lockoutDuration configure account lockout: after // lockoutThreshold consecutive failed attempts, the account is locked // (persistent, DB-backed — survives restarts, correct across multiple @@ -23,10 +29,12 @@ func Login( ctx context.Context, users store.UserStore, sessions store.SessionStore, + totpStore store.TOTPStore, hasher security.Hasher, ids security.IDGenerator, refreshGen token.TokenGenerator, jwtIssuer *token.JWTIssuer, + pendingIssuer *token.MFAPendingIssuer, limiter security.RateLimiter, audit store.AuditStore, log logger.Logger, @@ -95,6 +103,43 @@ func Login( log.Error("login: reset failed-attempts error", map[string]string{"error": err.Error(), "user_id": user.ID}) } + // Password verified. If this account has a confirmed TOTP secret, + // pause here instead of issuing tokens — a correct password alone + // is no longer sufficient to complete login. + if totpStore != nil { + secretRec, err := totpStore.GetByUserID(ctx, user.ID) + if err == nil && secretRec.ConfirmedAt != nil { + pendingToken, issueErr := pendingIssuer.Issue(user.ID) + if issueErr != nil { + return Tokens{}, issueErr + } + log.Info("login: password verified, awaiting TOTP", map[string]string{"user_id": user.ID}) + return Tokens{}, &ErrTOTPRequired{PendingToken: pendingToken} + } + } + + return finishLogin(ctx, sessions, ids, refreshGen, jwtIssuer, audit, log, user, callerIP, userAgent, "") +} + +// finishLogin issues a new session (access + refresh token pair) for +// an already-authenticated user. Shared by Login (password-only +// accounts) and CompleteLoginWithTOTP (accounts with 2FA) so both +// paths create sessions identically — a second factor changes how a +// caller gets here, never what a completed login produces. mfaMethod +// is recorded in the audit event's metadata ("" for password-only). +func finishLogin( + ctx context.Context, + sessions store.SessionStore, + ids security.IDGenerator, + refreshGen token.TokenGenerator, + jwtIssuer *token.JWTIssuer, + audit store.AuditStore, + log logger.Logger, + user store.User, + callerIP string, + userAgent string, + mfaMethod string, +) (Tokens, error) { sessionID, err := ids.New() if err != nil { return Tokens{}, err @@ -125,10 +170,15 @@ func Login( return Tokens{}, err } + var metadata map[string]string + if mfaMethod != "" { + metadata = map[string]string{"mfa": mfaMethod} + } if err := audit.Record(ctx, store.AuditEvent{ - Type: store.EventLoginSuccess, - UserID: user.ID, - IP: callerIP, + Type: store.EventLoginSuccess, + UserID: user.ID, + IP: callerIP, + Metadata: metadata, }); err != nil { log.Error("login: audit record failed", map[string]string{"error": err.Error(), "user_id": user.ID}) } diff --git a/auth/login_test.go b/auth/login_test.go index e771e6e..78e2432 100644 --- a/auth/login_test.go +++ b/auth/login_test.go @@ -31,7 +31,9 @@ func TestLogin_Success(t *testing.T) { hash, _ := hasher.Hash("correct-password") users.Create(ctx, storeUser("user-1", "proguy@example.com", hash)) - tokens, err := Login(ctx, users, sessions, hasher, ids, refreshGen, jwtIssuer, limiter, audit, log, + // totpStore/pendingIssuer are nil — TOTP not configured for this + // engine, Login must behave exactly as it did before TOTP existed. + tokens, err := Login(ctx, users, sessions, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "correct-password", "1.2.3.4", "test-agent", 5, time.Minute) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -49,7 +51,7 @@ func TestLogin_WrongPasswordRejected(t *testing.T) { hash, _ := hasher.Hash("correct-password") users.Create(ctx, storeUser("user-1", "proguy@example.com", hash)) - _, err := Login(ctx, users, sessions, hasher, ids, refreshGen, jwtIssuer, limiter, audit, log, + _, err := Login(ctx, users, sessions, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "wrong-password", "1.2.3.4", "test-agent", 5, time.Minute) if err != ErrInvalidCredentials { t.Errorf("expected ErrInvalidCredentials, got %v", err) @@ -63,7 +65,7 @@ func TestLogin_NonexistentUserRejectedWithSameError(t *testing.T) { log := testLogger{} ctx := context.Background() - _, err := Login(ctx, users, sessions, hasher, ids, refreshGen, jwtIssuer, limiter, audit, log, + _, err := Login(ctx, users, sessions, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "nobody@example.com", "any-password", "1.2.3.4", "test-agent", 5, time.Minute) if err != ErrInvalidCredentials { t.Errorf("expected ErrInvalidCredentials (same as wrong password), got %v", err) @@ -91,12 +93,12 @@ func TestLogin_NonexistentUserTimingMatchesWrongPassword(t *testing.T) { users.Create(ctx, storeUser("user-1", "proguy@example.com", hash)) start := time.Now() - Login(ctx, users, sessions, hasher, ids, refreshGen, jwtIssuer, limiter, audit, log, + Login(ctx, users, sessions, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "wrong-password", "1.2.3.4", "test-agent", 5, time.Minute) wrongPasswordDuration := time.Since(start) start = time.Now() - Login(ctx, users, sessions, hasher, ids, refreshGen, jwtIssuer, limiter, audit, log, + Login(ctx, users, sessions, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "nobody@example.com", "any-password", "1.2.3.4", "test-agent", 5, time.Minute) nonexistentUserDuration := time.Since(start) From c3354e2c73c570f89878fd581d93702f3081821d Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Sun, 30 Aug 2026 06:15:23 +0000 Subject: [PATCH 17/21] test: add Login/CompleteLoginWithTOTP integration tests Covers: confirmed TOTP pauses login and issues a pending token; no TOTP enrolled logs in directly as before; unconfirmed TOTP never gates login; correct/wrong code completion; a tampered pending token is rejected; and specifically, a real access token cannot be substituted for a pending token (the 'typ' claim check). --- auth/login_totp_test.go | 207 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 auth/login_totp_test.go diff --git a/auth/login_totp_test.go b/auth/login_totp_test.go new file mode 100644 index 0000000..6894118 --- /dev/null +++ b/auth/login_totp_test.go @@ -0,0 +1,207 @@ +package auth + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/crydensync/cryden/v2/security" + "github.com/crydensync/cryden/v2/store/memory" + "github.com/crydensync/cryden/v2/token" + "github.com/pquerna/otp/totp" +) + +func newTOTPLoginTestDeps(t *testing.T) (*memory.UserStore, *memory.SessionStore, *memory.TOTPStore, *memory.AuditStore, security.Hasher, security.IDGenerator, token.TokenGenerator, *token.JWTIssuer, *token.MFAPendingIssuer, security.RateLimiter, security.TOTPGenerator, security.Encryptor) { + t.Helper() + users := memory.NewUserStore() + sessions := memory.NewSessionStore() + totpStore := memory.NewTOTPStore() + audit := memory.NewAuditStore() + hasher, _ := security.NewBcryptHasher(4) + ids := security.NewUUIDv7Generator() + refreshGen, _ := token.NewCryptoRandTokenGenerator(32) + jwtIssuer, _ := token.NewJWTIssuer("test-secret", time.Minute) + pendingIssuer, _ := token.NewMFAPendingIssuer("test-secret") + limiter := security.NewInMemoryRateLimiter(1000, time.Minute) + totpGen := security.NewPquernaTOTPGenerator() + enc, _ := security.NewAESGCMEncryptor("test-encryption-key") + return users, sessions, totpStore, audit, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, totpGen, enc +} + +// enrollAndConfirm is a test helper that fully enrolls and confirms +// TOTP for a user, returning the plaintext secret so tests can +// generate real codes against it. +func enrollAndConfirm(t *testing.T, ctx context.Context, users *memory.UserStore, totpStore *memory.TOTPStore, audit *memory.AuditStore, totpGen security.TOTPGenerator, enc security.Encryptor, userID string) string { + t.Helper() + log := testLogger{} + if _, err := EnrollTOTP(ctx, users, totpStore, totpGen, enc, "CrydenSync", userID); err != nil { + t.Fatalf("enroll failed: %v", err) + } + secretRec, _ := totpStore.GetByUserID(ctx, userID) + plainSecret, _ := enc.Decrypt(secretRec.EncryptedSecret) + code, _ := totp.GenerateCode(plainSecret, time.Now()) + if err := ConfirmTOTP(ctx, totpStore, totpGen, enc, audit, log, userID, code); err != nil { + t.Fatalf("confirm failed: %v", err) + } + return plainSecret +} + +func TestLogin_WithConfirmedTOTPReturnsErrTOTPRequired(t *testing.T) { + users, sessions, totpStore, audit, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, totpGen, enc := newTOTPLoginTestDeps(t) + log := testLogger{} + ctx := context.Background() + + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") + + tokens, err := Login(ctx, users, sessions, totpStore, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, + "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4", "test-agent", 5, time.Minute) + + var totpRequired *ErrTOTPRequired + if !errors.As(err, &totpRequired) { + t.Fatalf("expected *ErrTOTPRequired, got %v", err) + } + if totpRequired.PendingToken == "" { + t.Error("expected a non-empty pending token") + } + if tokens.AccessToken != "" || tokens.RefreshToken != "" { + t.Error("expected no tokens to be issued before the second factor is completed") + } +} + +func TestLogin_WithoutTOTPConfiguredIssuesTokensDirectly(t *testing.T) { + // A user with no TOTP secret at all must log in exactly as before + // — the feature is purely additive per-account, never a default. + users, sessions, totpStore, audit, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, _, _ := newTOTPLoginTestDeps(t) + log := testLogger{} + ctx := context.Background() + + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + + tokens, err := Login(ctx, users, sessions, totpStore, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, + "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4", "test-agent", 5, time.Minute) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tokens.AccessToken == "" || tokens.RefreshToken == "" { + t.Error("expected tokens to be issued directly when TOTP isn't enrolled") + } +} + +func TestLogin_UnconfirmedTOTPDoesNotGateLogin(t *testing.T) { + // Enrollment alone (never confirmed) must never block a login — + // otherwise an interrupted enrollment flow would lock the user + // out of their own account. + users, sessions, totpStore, audit, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, totpGen, enc := newTOTPLoginTestDeps(t) + log := testLogger{} + ctx := context.Background() + + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + if _, err := EnrollTOTP(ctx, users, totpStore, totpGen, enc, "CrydenSync", "user-1"); err != nil { + t.Fatalf("enroll failed: %v", err) + } + + tokens, err := Login(ctx, users, sessions, totpStore, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, + "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4", "test-agent", 5, time.Minute) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tokens.AccessToken == "" { + t.Error("expected tokens to be issued — unconfirmed TOTP must not gate login") + } +} + +func TestCompleteLoginWithTOTP_CorrectCodeIssuesTokens(t *testing.T) { + users, sessions, totpStore, audit, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, totpGen, enc := newTOTPLoginTestDeps(t) + log := testLogger{} + ctx := context.Background() + + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + secret := enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") + + _, err := Login(ctx, users, sessions, totpStore, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, + "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4", "test-agent", 5, time.Minute) + var totpRequired *ErrTOTPRequired + if !errors.As(err, &totpRequired) { + t.Fatalf("expected *ErrTOTPRequired, got %v", err) + } + + code, _ := totp.GenerateCode(secret, time.Now()) + tokens, err := CompleteLoginWithTOTP(ctx, users, sessions, totpStore, totpGen, enc, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, + totpRequired.PendingToken, code, "1.2.3.4", "test-agent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tokens.AccessToken == "" || tokens.RefreshToken == "" { + t.Error("expected both tokens to be populated") + } +} + +func TestCompleteLoginWithTOTP_WrongCodeRejected(t *testing.T) { + users, sessions, totpStore, audit, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, totpGen, enc := newTOTPLoginTestDeps(t) + log := testLogger{} + ctx := context.Background() + + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") + + _, err := Login(ctx, users, sessions, totpStore, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, + "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4", "test-agent", 5, time.Minute) + var totpRequired *ErrTOTPRequired + errors.As(err, &totpRequired) + + _, err = CompleteLoginWithTOTP(ctx, users, sessions, totpStore, totpGen, enc, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, + totpRequired.PendingToken, "000000", "1.2.3.4", "test-agent") + if err != ErrInvalidTOTPCode { + t.Errorf("expected ErrInvalidTOTPCode, got %v", err) + } +} + +func TestCompleteLoginWithTOTP_TamperedPendingTokenRejected(t *testing.T) { + users, sessions, totpStore, audit, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, totpGen, enc := newTOTPLoginTestDeps(t) + log := testLogger{} + ctx := context.Background() + + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + secret := enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") + code, _ := totp.GenerateCode(secret, time.Now()) + + _, err := CompleteLoginWithTOTP(ctx, users, sessions, totpStore, totpGen, enc, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, + "not-a-real-token", code, "1.2.3.4", "test-agent") + if err != ErrInvalidPendingLogin { + t.Errorf("expected ErrInvalidPendingLogin, got %v", err) + } + _ = limiter +} + +func TestCompleteLoginWithTOTP_RejectsARealAccessTokenAsPendingToken(t *testing.T) { + // An access token and a pending-login token are both signed with + // the same secret. Verify's "typ" claim check is the only thing + // standing between "logged in" and "still needs a second factor" + // — this test exists specifically to catch a regression there. + users, sessions, totpStore, audit, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, totpGen, enc := newTOTPLoginTestDeps(t) + log := testLogger{} + ctx := context.Background() + + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + secret := enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") + code, _ := totp.GenerateCode(secret, time.Now()) + + realAccessToken, _ := jwtIssuer.Issue("user-1") + + _, err := CompleteLoginWithTOTP(ctx, users, sessions, totpStore, totpGen, enc, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, + realAccessToken, code, "1.2.3.4", "test-agent") + if err != ErrInvalidPendingLogin { + t.Errorf("expected ErrInvalidPendingLogin when handed a real access token, got %v", err) + } + + _ = limiter +} From 3c2c80160294ae934dcadf0a17e3447ed5bea1e9 Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Sun, 30 Aug 2026 06:15:29 +0000 Subject: [PATCH 18/21] feat: wire TOTP into Config, Engine, and the public facade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Config.TOTP (optional, store.TOTPStore), Config.EncryptionKey (required if TOTP is set), Config.TOTPIssuerName (optional, defaults to "Cryden"). - New() validates EncryptionKey is set whenever TOTP is, and constructs the pending-login issuer, encryptor, and TOTP generator only when TOTP is configured — they stay nil otherwise. - New facade functions: EnrollTOTP, ConfirmTOTP, DisableTOTP, CompleteLoginWithTOTP, each returning cryden.ErrTOTPNotConfigured if called without Config.TOTP set. - Login's facade signature is unchanged; it now threads e.totp and e.pendingIssuer through to auth.Login internally. --- config.go | 25 +++++++++++++++++++++++++ cryden.go | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- engine.go | 28 ++++++++++++++++++++++++++++ errors.go | 5 +++++ 4 files changed, 111 insertions(+), 2 deletions(-) diff --git a/config.go b/config.go index a1a7482..4fce554 100644 --- a/config.go +++ b/config.go @@ -29,6 +29,25 @@ type Config struct { // OAuth is optional — only required if LoginWithOAuth is used. // Left unset, LoginWithOAuth returns ErrOAuthNotConfigured. OAuth store.OAuthStore + // TOTP is optional — only required if EnrollTOTP / ConfirmTOTP / + // DisableTOTP / CompleteLoginWithTOTP are used. Left unset, those + // facade functions return ErrTOTPNotConfigured and Login never + // checks for a second factor. If set, EncryptionKey must also be + // set (validated below) — a TOTP secret is encrypted, not hashed, + // since the engine must recover it in plaintext to check codes. + TOTP store.TOTPStore + // EncryptionKey encrypts TOTP secrets at rest. Required only if + // TOTP is set. Like JWTSecret, this should be a long, random + // value kept out of source control — it is hashed internally to + // derive an AES-256 key, but that normalizes length only, it does + // not substitute for the input itself being high-entropy. + EncryptionKey string + // TOTPIssuerName is shown inside the user's authenticator app + // next to their account (e.g. "MyApp (user@example.com)"). + // Optional — defaults to "Cryden" if TOTP is set and this is left + // blank, but you almost certainly want to override it with your + // own app's name. + TOTPIssuerName string // Optional — sensible defaults applied in New() if zero-valued. // These are tuning knobs, not security-critical secrets, so @@ -56,6 +75,9 @@ func (c *Config) validate() error { if c.Audit == nil { return ErrMissingAuditStore } + if c.TOTP != nil && c.EncryptionKey == "" { + return ErrMissingEncryptionKey + } return nil } @@ -81,6 +103,9 @@ func (c *Config) applyDefaults() { if c.LockoutDuration == 0 { c.LockoutDuration = 15 * time.Minute } + if c.TOTP != nil && c.TOTPIssuerName == "" { + c.TOTPIssuerName = "Cryden" + } if c.Logger == nil { c.Logger = logger.NewConsoleJSONLogger() } diff --git a/cryden.go b/cryden.go index b5fae0c..21f5496 100644 --- a/cryden.go +++ b/cryden.go @@ -24,9 +24,13 @@ func SignUp(ctx context.Context, e *Engine, email, password, callerIP string) (s } // Login authenticates a user and issues a new session. callerIP and -// userAgent are required, caller-supplied. +// userAgent are required, caller-supplied. If the account has TOTP +// (2FA) enabled, no tokens are issued yet — Login returns +// *auth.ErrTOTPRequired (retrievable via errors.As) carrying a +// short-lived pending token; call CompleteLoginWithTOTP with that +// token plus a code to finish. func Login(ctx context.Context, e *Engine, email, password, callerIP, userAgent string) (Tokens, error) { - return auth.Login(ctx, e.users, e.sessions, e.hasher, e.ids, e.refreshGen, e.jwtIssuer, e.rateLimiter, e.audit, e.log, email, password, callerIP, userAgent, e.lockoutThreshold, e.lockoutDuration) + return auth.Login(ctx, e.users, e.sessions, e.totp, e.hasher, e.ids, e.refreshGen, e.jwtIssuer, e.pendingIssuer, e.rateLimiter, e.audit, e.log, email, password, callerIP, userAgent, e.lockoutThreshold, e.lockoutDuration) } // ChangePassword requires the caller's current password as @@ -180,3 +184,50 @@ func ListPublicSessions(ctx context.Context, e *Engine, userID string) ([]store. func RevokeSession(ctx context.Context, e *Engine, sessionID, userID string) error { return session.Revoke(ctx, e.sessions, e.audit, e.log, sessionID, userID) } + +// ErrTOTPNotConfigured is returned by every TOTP facade function +// below if the Engine was built without Config.TOTP (and +// Config.EncryptionKey) set. +var ErrTOTPNotConfigured = errors.New("cryden: TOTP requires Config.TOTP and Config.EncryptionKey to be set") + +// EnrollTOTP begins 2FA enrollment for an already-authenticated user. +// Returns an otpauth:// URL — render it as a QR code for the user to +// scan with an authenticator app. The secret does not gate login yet; +// call ConfirmTOTP with a code from the app to activate it. +func EnrollTOTP(ctx context.Context, e *Engine, userID string) (string, error) { + if e.totp == nil { + return "", ErrTOTPNotConfigured + } + return auth.EnrollTOTP(ctx, e.users, e.totp, e.totpGen, e.encryptor, e.totpIssuerName, userID) +} + +// ConfirmTOTP activates a pending TOTP enrollment once the user proves +// they've captured the secret by submitting one valid code from their +// authenticator app. +func ConfirmTOTP(ctx context.Context, e *Engine, userID, code string) error { + if e.totp == nil { + return ErrTOTPNotConfigured + } + return auth.ConfirmTOTP(ctx, e.totp, e.totpGen, e.encryptor, e.audit, e.log, userID, code) +} + +// DisableTOTP removes 2FA from an account. Requires the current +// password as re-confirmation, same reasoning as +// ChangePassword/DeleteAccount. +func DisableTOTP(ctx context.Context, e *Engine, userID, currentPassword string) error { + if e.totp == nil { + return ErrTOTPNotConfigured + } + return auth.DisableTOTP(ctx, e.users, e.totp, e.hasher, e.audit, e.log, userID, currentPassword) +} + +// CompleteLoginWithTOTP finishes a login that Login paused with +// *auth.ErrTOTPRequired (retrievable via errors.As). pendingToken is +// the value from that error; code is the current value from the +// user's authenticator app. +func CompleteLoginWithTOTP(ctx context.Context, e *Engine, pendingToken, code, callerIP, userAgent string) (Tokens, error) { + if e.totp == nil { + return Tokens{}, ErrTOTPNotConfigured + } + return auth.CompleteLoginWithTOTP(ctx, e.users, e.sessions, e.totp, e.totpGen, e.encryptor, e.ids, e.refreshGen, e.jwtIssuer, e.pendingIssuer, e.audit, e.log, pendingToken, code, callerIP, userAgent) +} diff --git a/engine.go b/engine.go index f740d91..8b80c76 100644 --- a/engine.go +++ b/engine.go @@ -20,12 +20,17 @@ type Engine struct { verifications store.VerificationStore emailSender notify.EmailSender oauth store.OAuthStore + totp store.TOTPStore hasher security.Hasher ids security.IDGenerator rateLimiter security.RateLimiter refreshGen token.TokenGenerator jwtIssuer *token.JWTIssuer + pendingIssuer *token.MFAPendingIssuer + totpGen security.TOTPGenerator + encryptor security.Encryptor + totpIssuerName string log logger.Logger lockoutThreshold int lockoutDuration time.Duration @@ -55,6 +60,24 @@ func New(cfg Config) (*Engine, error) { return nil, err } + // TOTP-related dependencies are only constructed if Config.TOTP + // is set — otherwise they stay nil, and Login/the TOTP facade + // functions treat that as "feature not configured." + var pendingIssuer *token.MFAPendingIssuer + var encryptor security.Encryptor + var totpGen security.TOTPGenerator + if cfg.TOTP != nil { + pendingIssuer, err = token.NewMFAPendingIssuer(cfg.JWTSecret) + if err != nil { + return nil, err + } + encryptor, err = security.NewAESGCMEncryptor(cfg.EncryptionKey) + if err != nil { + return nil, err + } + totpGen = security.NewPquernaTOTPGenerator() + } + return &Engine{ users: cfg.Users, sessions: cfg.Sessions, @@ -62,11 +85,16 @@ func New(cfg Config) (*Engine, error) { verifications: cfg.Verifications, emailSender: cfg.EmailSender, oauth: cfg.OAuth, + totp: cfg.TOTP, hasher: hasher, ids: security.NewUUIDv7Generator(), rateLimiter: security.NewInMemoryRateLimiter(cfg.RateLimitAttempts, cfg.RateLimitWindow), refreshGen: refreshGen, jwtIssuer: jwtIssuer, + pendingIssuer: pendingIssuer, + totpGen: totpGen, + encryptor: encryptor, + totpIssuerName: cfg.TOTPIssuerName, log: cfg.Logger, lockoutThreshold: cfg.LockoutThreshold, lockoutDuration: cfg.LockoutDuration, diff --git a/errors.go b/errors.go index 94604ed..ccec3dd 100644 --- a/errors.go +++ b/errors.go @@ -7,4 +7,9 @@ var ( ErrMissingUserStore = errors.New("cryden: Config.Users is required") ErrMissingSessionStore = errors.New("cryden: Config.Sessions is required") ErrMissingAuditStore = errors.New("cryden: Config.Audit is required") + // ErrMissingEncryptionKey is returned by New if Config.TOTP is set + // but Config.EncryptionKey isn't — a TOTP secret must be + // decryptable to validate codes against it, so it can't fall back + // to hashing (as passwords/tokens do) the way other secrets can. + ErrMissingEncryptionKey = errors.New("cryden: EncryptionKey is required when Config.TOTP is set") ) From 4fa237416c85e8a2d61bd6a49794a897f0f19ee3 Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Sun, 30 Aug 2026 06:15:35 +0000 Subject: [PATCH 19/21] docs: document TOTP (2FA) setup and usage in README --- README.md | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d2252a7..ca4a507 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,45 @@ err := cryden.LinkOAuthIdentity(ctx, engine, userID, "google", externalID, email `userID` must come from an already-verified session — never trust an email alone to authorize a link. Calling either function without `Config.OAuth` set returns `cryden.ErrOAuthNotConfigured`. +## Two-factor authentication (TOTP) + +Requires two additional `Config` fields: + +```go +engine, err := cryden.New(cryden.Config{ + // ...required fields... + TOTP: postgres.NewTOTPStore(db), // or memory.NewTOTPStore() + EncryptionKey: os.Getenv("ENCRYPTION_KEY"), // separate secret from JWTSecret + TOTPIssuerName: "YourApp", // shown in the user's authenticator app +}) +``` + +`EncryptionKey` is required whenever `TOTP` is set — a TOTP secret has to be recoverable in plaintext to validate codes against it, so (unlike passwords and tokens) it's encrypted rather than hashed. Use a different value from `JWTSecret`, not the same one twice. + +Enrollment is a two-step confirm flow — a secret never gates login until the user proves they've actually captured it: + +```go +otpauthURL, err := cryden.EnrollTOTP(ctx, engine, userID) +// render otpauthURL as a QR code for the user to scan + +err = cryden.ConfirmTOTP(ctx, engine, userID, codeFromApp) +// only after this succeeds does the account require a code to log in +``` + +Once confirmed, `Login` no longer issues tokens directly for that account — it returns `*auth.ErrTOTPRequired` (retrievable via `errors.As`) carrying a short-lived pending token: + +```go +tokens, err := cryden.Login(ctx, engine, email, password, callerIP, userAgent) + +var totpRequired *auth.ErrTOTPRequired +if errors.As(err, &totpRequired) { + // prompt for a code, then: + tokens, err = cryden.CompleteLoginWithTOTP(ctx, engine, totpRequired.PendingToken, code, callerIP, userAgent) +} +``` + +The pending token expires after 5 minutes and is only ever valid for completing that one login — it's a distinct token type from an access token, not just a permissive one. `DisableTOTP(ctx, engine, userID, currentPassword)` removes 2FA from an account and requires the current password as re-confirmation. Calling any TOTP function without `Config.TOTP` set returns `cryden.ErrTOTPNotConfigured`. + ## AI-assisted admin queries (library support only) The `ai` subpackage provides the safety machinery for natural-language admin tooling — an allowlisted `QueryIntent` type, `validateIntent`, and `ExecuteQuery` — plus `store/postgres.SafeQueryStore`, a read-only query executor. This is a foundation for tools like `csax`'s CLI to build on, not a feature you call directly in application code. An LLM's output is treated as untrusted data to validate against a strict allowlist, never as SQL to execute — and the actual DB connection passed to `SafeQueryStore` must be opened with a read-only Postgres role, since that's the real safety boundary, not just the allowlist check. `ai.LLMProvider` ships zero implementations; bring your own (OpenAI, Anthropic, OpenRouter, a local model). @@ -146,6 +185,7 @@ The `ai` subpackage provides the safety machinery for natural-language admin too - Signup, login, logout (single device + all devices) - OAuth login/signup (Google, GitHub, or any provider) with explicit, non-auto-linking account collision handling — see [OAuth](#oauth-google-github-or-any-provider) +- Two-factor authentication (TOTP) with encrypted-at-rest secrets and a confirm-before-enforce enrollment flow — see [Two-factor authentication](#two-factor-authentication-totp) - JWT access tokens + rotating opaque refresh tokens with theft/reuse detection - Session listing and revocation - Change password (requires current password, revokes all other sessions) @@ -160,7 +200,7 @@ The `ai` subpackage provides the safety machinery for natural-language admin too ## What's not in v2 (yet) -CLI, HTTP API, and language SDKs are separate repositories that wrap this engine — this repo is the core library only. MFA, magic links, SMS OTP, WebAuthn, SAML, and other advanced auth methods are planned for later releases. +CLI, HTTP API, and language SDKs are separate repositories that wrap this engine — this repo is the core library only. Magic links, SMS OTP, WebAuthn, SAML, and other advanced auth methods are planned for later releases. ## License From 295cb710671f124f914481592a1c950c3afa136d Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Sun, 30 Aug 2026 06:15:35 +0000 Subject: [PATCH 20/21] docs: add manual testing guide for 2FA/TOTP --- docs/testing/2fa-totp.md | 55 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 docs/testing/2fa-totp.md diff --git a/docs/testing/2fa-totp.md b/docs/testing/2fa-totp.md new file mode 100644 index 0000000..c15a70a --- /dev/null +++ b/docs/testing/2fa-totp.md @@ -0,0 +1,55 @@ +# Manual testing: 2FA (TOTP) + +## Fastest check — in-memory smoke test + +No database needed: + +```bash +go run ./cmd/smoketest/2fa-totp +``` + +This exercises the full flow against the in-memory store and prints a ✓/✗ line per step: + +1. Sign up a user +2. Login before TOTP is enrolled → tokens issued directly +3. Enroll TOTP → get back an `otpauth://` URL +4. Login again *before confirming* → tokens still issued directly (an unconfirmed secret must never gate login) +5. Confirm enrollment with a real generated code +6. Login again → paused with `*auth.ErrTOTPRequired`, no tokens issued +7. Complete login with a correct code → tokens issued +8. Complete login with a wrong code → rejected +9. Complete login with a tampered/garbage pending token → rejected +10. Attempt to use a real access token in place of a pending token → rejected (catches the "typ" claim check specifically) +11. Disable TOTP → login goes back to issuing tokens directly + +If every line prints ✓ and it ends with `ALL CHECKS PASSED`, the engine-level logic is sound. + +## Full check — against real Postgres + +1. Apply the migration: + ```bash + psql "$DATABASE_URL" -f store/postgres/migrations/0003_totp_secrets.up.sql + ``` +2. Set three env vars — `DATABASE_URL`, `JWT_SECRET`, and `ENCRYPTION_KEY` (must be different from `JWT_SECRET`, not reused). +3. Run the Postgres-backed version (see `cmd/smoketest/postgres-2fa-totp` if you kept it, or wire your own `main.go` following the `README.md` "Two-factor authentication" section — `Config.TOTP: postgres.NewTOTPStore(db)`). +4. Confirm in `psql` that a `totp_secrets` row was created on enroll, has `confirmed_at IS NULL` before confirmation, and is populated after. + +## Unit tests + +```bash +go test ./security/... ./auth/... ./store/... +``` + +Specifically relevant files: +- `security/totp_test.go` — code generation/validation, clock-skew window, wrong/expired code rejection +- `security/encryption_test.go` — encrypt/decrypt round-trip, different nonce per call, wrong key fails +- `auth/mfa_test.go` — enroll/confirm/disable, re-enrollment rejected once confirmed, wrong password blocks disable +- `auth/login_totp_test.go` — the full `Login` → `ErrTOTPRequired` → `CompleteLoginWithTOTP` handoff, plus the access-token-as-pending-token confusion test + +## What "working" looks like, in plain terms + +- An account with no TOTP enrolled logs in exactly as before — one call, tokens back immediately. +- Starting enrollment (`EnrollTOTP`) never affects login on its own — only a *confirmed* code does. +- Once confirmed, a correct password alone is no longer enough — `Login` returns an error, not tokens, and that error carries a short-lived pending token instead. +- That pending token is single-purpose: it only works with `CompleteLoginWithTOTP`, expires in 5 minutes, and a real access token can't be substituted for it. +- `DisableTOTP` requires the current password and immediately reverts the account to password-only login. From 2cb21f259b1939e6f87600df9b6f1f37465f1b50 Mon Sep 17 00:00:00 2001 From: Raymond Nicholas Date: Sun, 30 Aug 2026 06:15:35 +0000 Subject: [PATCH 21/21] feat: add in-memory smoke test for 2FA/TOTP Runnable end-to-end check with no database dependency: go run ./cmd/smoketest/2fa-totp. Walks the happy path (signup, login before enrollment, enroll, login before confirming, confirm, login paused, complete) and the negative cases (wrong confirm code, wrong login code, garbage pending token, a real access token substituted for a pending token, wrong password on disable), printing a pass/fail line per step. --- cmd/smoketest/2fa-totp/main.go | 189 +++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 cmd/smoketest/2fa-totp/main.go diff --git a/cmd/smoketest/2fa-totp/main.go b/cmd/smoketest/2fa-totp/main.go new file mode 100644 index 0000000..c002a4a --- /dev/null +++ b/cmd/smoketest/2fa-totp/main.go @@ -0,0 +1,189 @@ +// Command 2fa-totp is a standalone, no-database smoke test for the +// full TOTP (2FA) flow: enroll, confirm, login-pauses, complete, and +// the negative cases (wrong code, tampered pending token, a real +// access token used where a pending token is expected). Run with: +// +// go run ./cmd/smoketest/2fa-totp +package main + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "time" + + "github.com/crydensync/cryden/v2" + "github.com/crydensync/cryden/v2/auth" + "github.com/crydensync/cryden/v2/store/memory" + "github.com/pquerna/otp/totp" +) + +const ( + email = "raymondproguy@dev.com" + password = "Tr0ubl3-Fr33!2026" +) + +var failures int + +func main() { + ctx := context.Background() + + engine, err := cryden.New(cryden.Config{ + JWTSecret: "smoketest-jwt-secret", + Users: memory.NewUserStore(), + Sessions: memory.NewSessionStore(), + Audit: memory.NewAuditStore(), + TOTP: memory.NewTOTPStore(), + EncryptionKey: "smoketest-encryption-key", // deliberately different from JWTSecret + TOTPIssuerName: "CrydenSync Smoke Test", + }) + check("engine constructed", err) + + user, err := cryden.SignUp(ctx, engine, email, password, "1.2.3.4") + check("signed up", err) + + // 1. Login before TOTP is enrolled — must succeed directly. + _, err = cryden.Login(ctx, engine, email, password, "1.2.3.4", "smoketest-agent") + check("login before enrollment issues tokens directly", err) + + // 2. Enroll TOTP. + otpauthURL, err := cryden.EnrollTOTP(ctx, engine, user.ID) + check("enrolled TOTP", err) + + secret, err := extractSecretFromURL(otpauthURL) + check("extracted secret from otpauth URL", err) + + // 3. Login before confirming — must still succeed directly. An + // unconfirmed secret must never gate login. + _, err = cryden.Login(ctx, engine, email, password, "1.2.3.4", "smoketest-agent") + check("login with UNCONFIRMED TOTP still issues tokens directly", err) + + // 4. Confirming with a wrong code must fail, and must not confirm. + err = cryden.ConfirmTOTP(ctx, engine, user.ID, "000000") + checkExpectError("confirm with wrong code is rejected", err) + + _, err = cryden.Login(ctx, engine, email, password, "1.2.3.4", "smoketest-agent") + check("login still issues tokens directly after a failed confirm attempt", err) + + // 5. Confirm with a real code. + code, err := totp.GenerateCode(secret, time.Now()) + check("generated a real code", err) + err = cryden.ConfirmTOTP(ctx, engine, user.ID, code) + check("confirmed TOTP enrollment", err) + + // 6. Login now — must pause with *auth.ErrTOTPRequired, no tokens. + pendingToken1 := requirePending(ctx, engine, "login after confirmation returns *auth.ErrTOTPRequired") + + // 7. Complete with a wrong code — must be rejected. + _, err = cryden.CompleteLoginWithTOTP(ctx, engine, pendingToken1, "000000", "1.2.3.4", "smoketest-agent") + checkExpectError("complete login with wrong code is rejected", err) + + // 8. Complete with a tampered/garbage pending token — must be rejected. + code, _ = totp.GenerateCode(secret, time.Now()) + _, err = cryden.CompleteLoginWithTOTP(ctx, engine, "not-a-real-pending-token", code, "1.2.3.4", "smoketest-agent") + checkExpectError("complete login with a garbage pending token is rejected", err) + + // 9. Correct code completes login successfully. + code, _ = totp.GenerateCode(secret, time.Now()) + realTokens, err := cryden.CompleteLoginWithTOTP(ctx, engine, pendingToken1, code, "1.2.3.4", "smoketest-agent") + check("completed login with a correct code", err) + if realTokens.AccessToken == "" || realTokens.RefreshToken == "" { + fail("expected both tokens to be populated after successful completion") + } else { + pass("both tokens populated after successful completion") + } + + // 10. Use that REAL access token where a pending token is + // expected — must be rejected. Both are signed with the same + // secret; this specifically checks the "typ" claim guarding + // against confusion between the two token types. + pendingToken2 := requirePending(ctx, engine, "login still requires 2FA on the next attempt") + code, _ = totp.GenerateCode(secret, time.Now()) + _, err = cryden.CompleteLoginWithTOTP(ctx, engine, realTokens.AccessToken, code, "1.2.3.4", "smoketest-agent") + checkExpectError("using a real access token as a pending token is rejected", err) + + // Clean up that still-pending login before moving on. + code, _ = totp.GenerateCode(secret, time.Now()) + _, err = cryden.CompleteLoginWithTOTP(ctx, engine, pendingToken2, code, "1.2.3.4", "smoketest-agent") + check("completed the pending login from step 10", err) + + // 11. Disable TOTP with the wrong password — must be rejected, secret stays. + err = cryden.DisableTOTP(ctx, engine, user.ID, "wrong-password") + checkExpectError("disable TOTP with wrong password is rejected", err) + + // 12. Disable TOTP with the correct password — login goes back to direct. + err = cryden.DisableTOTP(ctx, engine, user.ID, password) + check("disabled TOTP with correct password", err) + + _, err = cryden.Login(ctx, engine, email, password, "1.2.3.4", "smoketest-agent") + check("login after disabling TOTP issues tokens directly again", err) + + fmt.Println() + if failures == 0 { + fmt.Println("ALL CHECKS PASSED") + } else { + fmt.Printf("%d CHECK(S) FAILED\n", failures) + os.Exit(1) + } +} + +// requirePending logs in and asserts the account is correctly paused +// on *auth.ErrTOTPRequired, returning the pending token for the +// caller to complete or probe against. +func requirePending(ctx context.Context, engine *cryden.Engine, step string) string { + tokens, err := cryden.Login(ctx, engine, email, password, "1.2.3.4", "smoketest-agent") + var totpRequired *auth.ErrTOTPRequired + if !errors.As(err, &totpRequired) { + fail(fmt.Sprintf("%s: expected *auth.ErrTOTPRequired, got %v", step, err)) + return "" + } + if tokens.AccessToken != "" { + fail(fmt.Sprintf("%s: expected no access token to be issued", step)) + } + if totpRequired.PendingToken == "" { + fail(fmt.Sprintf("%s: expected a non-empty pending token", step)) + } + pass(step) + return totpRequired.PendingToken +} + +func extractSecretFromURL(otpauthURL string) (string, error) { + u, err := url.Parse(otpauthURL) + if err != nil { + return "", err + } + secret := u.Query().Get("secret") + if secret == "" { + return "", fmt.Errorf("no secret query param found in %q", otpauthURL) + } + return secret, nil +} + +func check(step string, err error) { + if err != nil { + fail(fmt.Sprintf("%s: unexpected error: %v", step, err)) + return + } + pass(step) +} + +// checkExpectError is used for the negative cases — a nil error here +// is the failure. +func checkExpectError(step string, err error) { + if err == nil { + fail(fmt.Sprintf("%s: expected an error, got nil", step)) + return + } + pass(fmt.Sprintf("%s (%v)", step, err)) +} + +func pass(step string) { + fmt.Println("✓", step) +} + +func fail(msg string) { + failures++ + fmt.Println("✗", msg) +}