// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/auth/verify_ott.go package auth import ( "context" "crypto/rand" "crypto/subtle" "encoding/base64" "fmt" "strings" "time" "github.com/awnumar/memguard" "github.com/gocql/gocql" "go.uber.org/zap" uc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/security/crypto" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/storage/cache/cassandracache" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/transaction" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation" ) type VerifyOTTRequestDTO struct { Email string `json:"email"` OTT string `json:"ott"` } type VerifyOTTResponseDTO struct { Message string `json:"message"` ChallengeID string `json:"challengeId"` EncryptedChallenge string `json:"encryptedChallenge"` Salt string `json:"salt"` EncryptedMasterKey string `json:"encryptedMasterKey"` EncryptedPrivateKey string `json:"encryptedPrivateKey"` PublicKey string `json:"publicKey"` // KDFAlgorithm specifies which key derivation algorithm to use. // Values: "PBKDF2-SHA256" (web frontend) or "argon2id" (native app legacy) KDFAlgorithm string `json:"kdfAlgorithm"` } type VerifyOTTService interface { Execute(ctx context.Context, req *VerifyOTTRequestDTO) (*VerifyOTTResponseDTO, error) } type verifyOTTServiceImpl struct { logger *zap.Logger userGetByEmailUC uc_user.UserGetByEmailUseCase cache cassandracache.CassandraCacher } func NewVerifyOTTService( logger *zap.Logger, userGetByEmailUC uc_user.UserGetByEmailUseCase, cache cassandracache.CassandraCacher, ) VerifyOTTService { return &verifyOTTServiceImpl{ logger: logger.Named("VerifyOTTService"), userGetByEmailUC: userGetByEmailUC, cache: cache, } } func (s *verifyOTTServiceImpl) Execute(ctx context.Context, req *VerifyOTTRequestDTO) (*VerifyOTTResponseDTO, error) { // Validate request if err := s.validateVerifyOTTRequest(req); err != nil { return nil, err // Returns RFC 9457 ProblemDetail } // Create SAGA for OTT verification workflow saga := transaction.NewSaga("verify-ott", s.logger) s.logger.Info("starting OTT verification") // Step 1: Normalize email email := strings.ToLower(strings.TrimSpace(req.Email)) // Step 2: Get OTT from cache cacheKey := fmt.Sprintf("ott:%s", email) cachedOTT, err := s.cache.Get(ctx, cacheKey) if err != nil || cachedOTT == nil { s.logger.Warn("OTT not found in cache", zap.String("email", validation.MaskEmail(email))) return nil, httperror.NewUnauthorizedError("Invalid or expired verification code. Please request a new code.") } defer memguard.WipeBytes(cachedOTT) // SECURITY: Wipe OTT from memory after use // Step 3: Verify OTT matches using constant-time comparison // CWE-208: Prevents timing attacks by ensuring comparison takes same time regardless of match if subtle.ConstantTimeCompare(cachedOTT, []byte(req.OTT)) != 1 { s.logger.Warn("OTT mismatch", zap.String("email", validation.MaskEmail(email))) return nil, httperror.NewUnauthorizedError("Incorrect verification code. Please check the code and try again.") } // Step 4: Get user to retrieve encrypted keys (read-only, no compensation) user, err := s.userGetByEmailUC.Execute(ctx, email) if err != nil || user == nil { s.logger.Error("User not found after OTT verification", zap.String("email", validation.MaskEmail(email)), zap.Error(err)) return nil, httperror.NewUnauthorizedError("User account not found. Please contact support.") } // Step 5: Generate random challenge (32 bytes) challenge := make([]byte, 32) if _, err := rand.Read(challenge); err != nil { s.logger.Error("Failed to generate challenge", zap.Error(err)) return nil, httperror.NewInternalServerError("Failed to generate security challenge. Please try again.") } defer memguard.WipeBytes(challenge) // SECURITY: Wipe challenge from memory after use // Step 6: Generate challenge ID challengeID := gocql.TimeUUID().String() // Step 7: Store challenge in cache FIRST (compensate: delete challenge) // CRITICAL: Store challenge before deleting OTT to prevent lockout challengeKey := fmt.Sprintf("challenge:%s", challengeID) if err := s.cache.SetWithExpiry(ctx, challengeKey, challenge, 5*time.Minute); err != nil { s.logger.Error("Failed to store challenge", zap.Error(err)) return nil, httperror.NewInternalServerError("Failed to store security challenge. Please try again.") } // Register compensation: delete challenge if OTT deletion fails challengeKeyCaptured := challengeKey saga.AddCompensation(func(ctx context.Context) error { s.logger.Info("compensating: deleting challenge", zap.String("challenge_key", challengeKeyCaptured)) return s.cache.Delete(ctx, challengeKeyCaptured) }) // Step 8: Delete OTT from cache (one-time use) (compensate: restore OTT) cacheKeyCaptured := cacheKey cachedOTTCaptured := cachedOTT if err := s.cache.Delete(ctx, cacheKey); err != nil { s.logger.Error("Failed to delete OTT", zap.String("cache_key", cacheKey), zap.Error(err)) // Trigger compensation: Delete challenge saga.Rollback(ctx) return nil, httperror.NewInternalServerError("Verification failed. Please try again.") } // Register compensation: restore OTT with reduced TTL (5 minutes for retry) saga.AddCompensation(func(ctx context.Context) error { s.logger.Info("compensating: restoring OTT", zap.String("cache_key", cacheKeyCaptured)) // Restore with reduced TTL (5 minutes) to allow user retry return s.cache.SetWithExpiry(ctx, cacheKeyCaptured, cachedOTTCaptured, 5*time.Minute) }) // Encrypt the challenge with the user's public key using NaCl sealed box encryptedChallengeBytes, err := crypto.EncryptWithPublicKey(challenge, user.SecurityData.PublicKey.Key) if err != nil { s.logger.Error("Failed to encrypt challenge", zap.Error(err)) return nil, httperror.NewInternalServerError("Failed to encrypt security challenge. Please try again.") } defer memguard.WipeBytes(encryptedChallengeBytes) // SECURITY: Wipe encrypted challenge after encoding encryptedChallenge := base64.StdEncoding.EncodeToString(encryptedChallengeBytes) s.logger.Info("OTT verified successfully", zap.String("email", validation.MaskEmail(email)), zap.String("challenge_id", challengeID), zap.String("challenge_key", challengeKey[:16]+"...")) // Log prefix for security // Prepare user's encrypted keys for frontend salt := base64.StdEncoding.EncodeToString(user.SecurityData.PasswordSalt) encryptedMasterKey := base64.StdEncoding.EncodeToString(append(user.SecurityData.EncryptedMasterKey.Nonce, user.SecurityData.EncryptedMasterKey.Ciphertext...)) encryptedPrivateKey := base64.StdEncoding.EncodeToString(append(user.SecurityData.EncryptedPrivateKey.Nonce, user.SecurityData.EncryptedPrivateKey.Ciphertext...)) publicKey := base64.StdEncoding.EncodeToString(user.SecurityData.PublicKey.Key) // Get KDF algorithm from user's security data kdfAlgorithm := user.SecurityData.KDFParams.Algorithm if kdfAlgorithm == "" { // Default to argon2id for backward compatibility with old accounts kdfAlgorithm = "argon2id" } return &VerifyOTTResponseDTO{ Message: "OTT verified. Please decrypt the challenge with your master key.", ChallengeID: challengeID, EncryptedChallenge: encryptedChallenge, Salt: salt, EncryptedMasterKey: encryptedMasterKey, EncryptedPrivateKey: encryptedPrivateKey, PublicKey: publicKey, KDFAlgorithm: kdfAlgorithm, }, nil } // validateVerifyOTTRequest validates the verify OTT request. // Returns RFC 9457 ProblemDetail error with field-specific errors. func (s *verifyOTTServiceImpl) validateVerifyOTTRequest(req *VerifyOTTRequestDTO) error { errors := make(map[string]string) // Validate email using shared validation utility if errMsg := validation.ValidateEmail(req.Email); errMsg != "" { errors["email"] = errMsg } // Validate OTT code ott := strings.TrimSpace(req.OTT) if ott == "" { errors["ott"] = "Verification code is required" } else if len(ott) != 8 { errors["ott"] = "Verification code must be 8 digits" } else { // Check if all characters are digits for _, c := range ott { if c < '0' || c > '9' { errors["ott"] = "Verification code must contain only numbers" break } } } // If there are validation errors, return RFC 9457 error if len(errors) > 0 { return httperror.NewValidationError(errors) } return nil }