// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/auth/recovery_verify.go package auth import ( "bytes" "context" "crypto/rand" "encoding/base64" "fmt" "time" "github.com/awnumar/memguard" "go.uber.org/zap" uc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user" "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 RecoveryVerifyRequestDTO struct { SessionID string `json:"session_id"` DecryptedChallenge string `json:"decrypted_challenge"` } type RecoveryVerifyResponseDTO struct { Message string `json:"message"` RecoveryToken string `json:"recovery_token"` CanResetCredentials bool `json:"can_reset_credentials"` MasterKeyEncryptedWithRecoveryKey string `json:"master_key_encrypted_with_recovery_key"` } type RecoveryVerifyService interface { Execute(ctx context.Context, req *RecoveryVerifyRequestDTO) (*RecoveryVerifyResponseDTO, error) } type recoveryVerifyServiceImpl struct { logger *zap.Logger cache cassandracache.CassandraCacher userGetByEmailUC uc_user.UserGetByEmailUseCase } func NewRecoveryVerifyService( logger *zap.Logger, cache cassandracache.CassandraCacher, userGetByEmailUC uc_user.UserGetByEmailUseCase, ) RecoveryVerifyService { return &recoveryVerifyServiceImpl{ logger: logger.Named("RecoveryVerifyService"), cache: cache, userGetByEmailUC: userGetByEmailUC, } } func (s *recoveryVerifyServiceImpl) Execute(ctx context.Context, req *RecoveryVerifyRequestDTO) (*RecoveryVerifyResponseDTO, error) { // Create SAGA for recovery verify workflow saga := transaction.NewSaga("recovery-verify", s.logger) s.logger.Info("starting recovery verification") // Step 1: Get the original challenge from cache challengeKey := fmt.Sprintf("recovery_challenge:%s", req.SessionID) originalChallenge, err := s.cache.Get(ctx, challengeKey) if err != nil || originalChallenge == nil { s.logger.Warn("Recovery challenge not found or expired", zap.String("session_id", req.SessionID)) return nil, fmt.Errorf("invalid or expired recovery session") } defer memguard.WipeBytes(originalChallenge) // SECURITY: Wipe challenge from memory // Step 2: Decode the decrypted challenge from base64 decryptedChallenge, err := base64.StdEncoding.DecodeString(req.DecryptedChallenge) if err != nil { s.logger.Warn("Failed to decode decrypted challenge", zap.Error(err)) return nil, fmt.Errorf("invalid decrypted challenge format") } defer memguard.WipeBytes(decryptedChallenge) // SECURITY: Wipe decrypted challenge from memory // Step 3: Verify that decrypted challenge matches original if !bytes.Equal(decryptedChallenge, originalChallenge) { s.logger.Warn("Recovery challenge verification failed", zap.String("session_id", req.SessionID)) return nil, fmt.Errorf("challenge verification failed") } // Step 4: Generate recovery token (random secure token) tokenBytes := make([]byte, 32) if _, err := rand.Read(tokenBytes); err != nil { s.logger.Error("Failed to generate recovery token", zap.Error(err)) return nil, fmt.Errorf("failed to generate recovery token") } defer memguard.WipeBytes(tokenBytes) // SECURITY: Wipe token bytes from memory recoveryToken := base64.URLEncoding.EncodeToString(tokenBytes) // Step 5: Get email associated with recovery session (read-only, no compensation) emailKey := fmt.Sprintf("recovery_email:%s", req.SessionID) email, err := s.cache.Get(ctx, emailKey) if err != nil || email == nil { s.logger.Error("Recovery email not found", zap.String("session_id", req.SessionID)) return nil, fmt.Errorf("recovery session invalid") } // Step 5b: Fetch user to get their encrypted master key with recovery key user, err := s.userGetByEmailUC.Execute(ctx, string(email)) if err != nil || user == nil { s.logger.Error("User not found for recovery", zap.String("email", validation.MaskEmail(string(email)))) return nil, fmt.Errorf("user not found") } // Validate user has the required key data if user.SecurityData == nil || user.SecurityData.MasterKeyEncryptedWithRecoveryKey.Ciphertext == nil || user.SecurityData.MasterKeyEncryptedWithRecoveryKey.Nonce == nil { s.logger.Error("User missing master key encrypted with recovery key", zap.String("email", validation.MaskEmail(string(email)))) return nil, fmt.Errorf("account recovery data not available") } // Combine nonce + ciphertext for transmission (matches frontend expectation) // Format: nonce (24 bytes) || ciphertext (variable length) nonce := user.SecurityData.MasterKeyEncryptedWithRecoveryKey.Nonce ciphertext := user.SecurityData.MasterKeyEncryptedWithRecoveryKey.Ciphertext combined := make([]byte, len(nonce)+len(ciphertext)) copy(combined[:len(nonce)], nonce) copy(combined[len(nonce):], ciphertext) defer memguard.WipeBytes(combined) // SECURITY: Wipe combined key data from memory // Encode the combined data to base64 for transmission masterKeyEncryptedWithRecoveryKeyBase64 := base64.StdEncoding.EncodeToString(combined) // Step 6: Store recovery token FIRST (compensate: delete recovery token) // CRITICAL: Store recovery token before deleting challenge to prevent flow interruption tokenKey := fmt.Sprintf("recovery_token:%s", recoveryToken) if err := s.cache.SetWithExpiry(ctx, tokenKey, email, 15*time.Minute); err != nil { s.logger.Error("Failed to store recovery token", zap.Error(err)) return nil, fmt.Errorf("failed to complete recovery verification") } // Register compensation: delete recovery token if challenge deletion fails tokenKeyCaptured := tokenKey saga.AddCompensation(func(ctx context.Context) error { s.logger.Info("compensating: deleting recovery token", zap.String("token_key", tokenKeyCaptured)) return s.cache.Delete(ctx, tokenKeyCaptured) }) // Step 7: Clear recovery challenge (one-time use) (compensate: restore challenge) challengeKeyCaptured := challengeKey originalChallengeCaptured := originalChallenge if err := s.cache.Delete(ctx, challengeKey); err != nil { s.logger.Error("Failed to delete recovery challenge", zap.String("challenge_key", challengeKey), zap.Error(err)) // Trigger compensation: Delete recovery token saga.Rollback(ctx) return nil, fmt.Errorf("failed to delete recovery challenge: %w", err) } // Register compensation: restore challenge with reduced TTL (15 minutes for retry) saga.AddCompensation(func(ctx context.Context) error { s.logger.Info("compensating: restoring recovery challenge", zap.String("challenge_key", challengeKeyCaptured)) // Restore with same TTL (15 minutes) to allow user retry return s.cache.SetWithExpiry(ctx, challengeKeyCaptured, originalChallengeCaptured, 15*time.Minute) }) s.logger.Info("Recovery verification successful", zap.String("session_id", req.SessionID), zap.String("email", validation.MaskEmail(string(email))), zap.String("recovery_token", recoveryToken[:16]+"...")) // Log prefix for security return &RecoveryVerifyResponseDTO{ Message: "Recovery challenge verified successfully. You can now reset your credentials.", RecoveryToken: recoveryToken, CanResetCredentials: true, MasterKeyEncryptedWithRecoveryKey: masterKeyEncryptedWithRecoveryKeyBase64, }, nil }