// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/auth/recovery_initiate.go package auth import ( "context" "crypto/rand" "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/auditlog" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/storage/cache/cassandracache" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation" ) type RecoveryInitiateRequestDTO struct { Email string `json:"email"` Method string `json:"method"` // "recovery_key" } type RecoveryInitiateResponseDTO struct { Message string `json:"message"` SessionID string `json:"session_id"` EncryptedChallenge string `json:"encrypted_challenge"` } type RecoveryInitiateService interface { Execute(ctx context.Context, req *RecoveryInitiateRequestDTO) (*RecoveryInitiateResponseDTO, error) } type recoveryInitiateServiceImpl struct { logger *zap.Logger auditLogger auditlog.AuditLogger userGetByEmailUC uc_user.UserGetByEmailUseCase cache cassandracache.CassandraCacher } func NewRecoveryInitiateService( logger *zap.Logger, auditLogger auditlog.AuditLogger, userGetByEmailUC uc_user.UserGetByEmailUseCase, cache cassandracache.CassandraCacher, ) RecoveryInitiateService { return &recoveryInitiateServiceImpl{ logger: logger.Named("RecoveryInitiateService"), auditLogger: auditLogger, userGetByEmailUC: userGetByEmailUC, cache: cache, } } func (s *recoveryInitiateServiceImpl) Execute(ctx context.Context, req *RecoveryInitiateRequestDTO) (*RecoveryInitiateResponseDTO, error) { // Normalize email email := strings.ToLower(strings.TrimSpace(req.Email)) // Verify user exists user, err := s.userGetByEmailUC.Execute(ctx, email) if err != nil || user == nil { // For security, don't reveal if user exists or not s.logger.Warn("User not found for recovery", zap.String("email", validation.MaskEmail(email))) // Generate fake session ID and challenge to prevent timing attacks and enumeration // This ensures the response looks identical whether the user exists or not fakeSessionID := gocql.TimeUUID().String() fakeChallenge := make([]byte, 32) if _, err := rand.Read(fakeChallenge); err != nil { // Fallback to zeros if random fails (extremely unlikely) fakeChallenge = make([]byte, 32) } defer memguard.WipeBytes(fakeChallenge) // SECURITY: Wipe fake challenge from memory fakeEncryptedChallenge := base64.StdEncoding.EncodeToString(fakeChallenge) return &RecoveryInitiateResponseDTO{ Message: "Recovery initiated. Please decrypt the challenge with your recovery key.", SessionID: fakeSessionID, EncryptedChallenge: fakeEncryptedChallenge, }, nil } // Generate recovery session ID sessionID := gocql.TimeUUID().String() // Generate random challenge (32 bytes) challenge := make([]byte, 32) if _, err := rand.Read(challenge); err != nil { s.logger.Error("Failed to generate recovery challenge", zap.Error(err)) return nil, fmt.Errorf("failed to initiate recovery") } defer memguard.WipeBytes(challenge) // SECURITY: Wipe challenge from memory after use // Store recovery challenge in cache (30 minute expiry) challengeKey := fmt.Sprintf("recovery_challenge:%s", sessionID) if err := s.cache.SetWithExpiry(ctx, challengeKey, challenge, 30*time.Minute); err != nil { s.logger.Error("Failed to store recovery challenge", zap.Error(err)) return nil, fmt.Errorf("failed to initiate recovery") } // Store email associated with recovery session emailKey := fmt.Sprintf("recovery_email:%s", sessionID) if err := s.cache.SetWithExpiry(ctx, emailKey, []byte(email), 30*time.Minute); err != nil { s.logger.Error("Failed to store recovery email", zap.Error(err)) // Continue anyway } // NOTE: In a real implementation with recovery key encryption: // - We would retrieve the user's encrypted recovery key // - Encrypt the challenge with it // - The client would decrypt with their recovery key // For now, return base64-encoded challenge (frontend will handle encryption) encryptedChallenge := base64.StdEncoding.EncodeToString(challenge) s.logger.Info("Recovery initiated successfully", zap.String("email", validation.MaskEmail(email)), zap.String("session_id", sessionID)) // Audit log recovery initiation s.auditLogger.LogAuth(ctx, auditlog.EventTypeRecoveryInitiated, auditlog.OutcomeSuccess, validation.MaskEmail(email), "", map[string]string{ "session_id": sessionID, }) return &RecoveryInitiateResponseDTO{ Message: "Recovery initiated. Please decrypt the challenge with your recovery key.", SessionID: sessionID, EncryptedChallenge: encryptedChallenge, }, nil }