// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/auth/recovery_complete.go package auth import ( "context" "encoding/base64" "fmt" "time" "github.com/awnumar/memguard" "go.uber.org/zap" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/crypto" dom_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/user" 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/transaction" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation" ) type RecoveryCompleteRequestDTO struct { RecoveryToken string `json:"recovery_token"` NewSalt string `json:"new_salt"` NewPublicKey string `json:"new_public_key"` NewEncryptedMasterKey string `json:"new_encrypted_master_key"` NewEncryptedPrivateKey string `json:"new_encrypted_private_key"` NewEncryptedRecoveryKey string `json:"new_encrypted_recovery_key"` NewMasterKeyEncryptedWithRecoveryKey string `json:"new_master_key_encrypted_with_recovery_key"` } type RecoveryCompleteResponseDTO struct { Message string `json:"message"` Success bool `json:"success"` } type RecoveryCompleteService interface { Execute(ctx context.Context, req *RecoveryCompleteRequestDTO) (*RecoveryCompleteResponseDTO, error) } type recoveryCompleteServiceImpl struct { logger *zap.Logger auditLogger auditlog.AuditLogger userGetByEmailUC uc_user.UserGetByEmailUseCase userUpdateUC uc_user.UserUpdateUseCase cache cassandracache.CassandraCacher } func NewRecoveryCompleteService( logger *zap.Logger, auditLogger auditlog.AuditLogger, userGetByEmailUC uc_user.UserGetByEmailUseCase, userUpdateUC uc_user.UserUpdateUseCase, cache cassandracache.CassandraCacher, ) RecoveryCompleteService { return &recoveryCompleteServiceImpl{ logger: logger.Named("RecoveryCompleteService"), auditLogger: auditLogger, userGetByEmailUC: userGetByEmailUC, userUpdateUC: userUpdateUC, cache: cache, } } func (s *recoveryCompleteServiceImpl) Execute(ctx context.Context, req *RecoveryCompleteRequestDTO) (*RecoveryCompleteResponseDTO, error) { // Create SAGA for recovery completion workflow saga := transaction.NewSaga("recovery-complete", s.logger) s.logger.Info("starting recovery completion") // Step 1: Validate recovery token from cache tokenKey := fmt.Sprintf("recovery_token:%s", req.RecoveryToken) emailBytes, err := s.cache.Get(ctx, tokenKey) if err != nil || emailBytes == nil { s.logger.Warn("Recovery token not found or expired") return nil, fmt.Errorf("invalid or expired recovery token") } email := string(emailBytes) // Step 2: Get user by email and backup current credentials user, err := s.userGetByEmailUC.Execute(ctx, email) if err != nil || user == nil { s.logger.Error("User not found during recovery completion", zap.String("email", validation.MaskEmail(email))) return nil, fmt.Errorf("recovery completion failed") } // Backup current credentials for compensation (deep copy) var oldSecurityData *dom_user.UserSecurityData if user.SecurityData != nil { // Create a deep copy of security data oldSecurityData = &dom_user.UserSecurityData{ PasswordSalt: make([]byte, len(user.SecurityData.PasswordSalt)), PublicKey: user.SecurityData.PublicKey, EncryptedMasterKey: user.SecurityData.EncryptedMasterKey, EncryptedPrivateKey: user.SecurityData.EncryptedPrivateKey, EncryptedRecoveryKey: user.SecurityData.EncryptedRecoveryKey, MasterKeyEncryptedWithRecoveryKey: user.SecurityData.MasterKeyEncryptedWithRecoveryKey, } copy(oldSecurityData.PasswordSalt, user.SecurityData.PasswordSalt) } // Decode new encryption keys from base64 // SECURITY: All decoded key material is wiped from memory after use newSalt, err := base64.StdEncoding.DecodeString(req.NewSalt) if err != nil { return nil, fmt.Errorf("invalid salt format") } defer memguard.WipeBytes(newSalt) newPublicKey, err := base64.StdEncoding.DecodeString(req.NewPublicKey) if err != nil { return nil, fmt.Errorf("invalid public key format") } defer memguard.WipeBytes(newPublicKey) newEncryptedMasterKey, err := base64.StdEncoding.DecodeString(req.NewEncryptedMasterKey) if err != nil { return nil, fmt.Errorf("invalid encrypted master key format") } defer memguard.WipeBytes(newEncryptedMasterKey) newEncryptedPrivateKey, err := base64.StdEncoding.DecodeString(req.NewEncryptedPrivateKey) if err != nil { return nil, fmt.Errorf("invalid encrypted private key format") } defer memguard.WipeBytes(newEncryptedPrivateKey) newEncryptedRecoveryKey, err := base64.StdEncoding.DecodeString(req.NewEncryptedRecoveryKey) if err != nil { return nil, fmt.Errorf("invalid encrypted recovery key format") } defer memguard.WipeBytes(newEncryptedRecoveryKey) newMasterKeyEncryptedWithRecovery, err := base64.StdEncoding.DecodeString(req.NewMasterKeyEncryptedWithRecoveryKey) if err != nil { return nil, fmt.Errorf("invalid master key encrypted with recovery format") } defer memguard.WipeBytes(newMasterKeyEncryptedWithRecovery) // Update user's encryption keys if user.SecurityData == nil { user.SecurityData = &dom_user.UserSecurityData{} } // Parse the encrypted keys into their proper structures // Format: nonce (24 bytes) + ciphertext (remaining bytes) // Update password salt user.SecurityData.PasswordSalt = newSalt // Update public key (critical for login challenge encryption) user.SecurityData.PublicKey = crypto.PublicKey{ Key: newPublicKey, } // Update encrypted master key if len(newEncryptedMasterKey) > 24 { user.SecurityData.EncryptedMasterKey = crypto.EncryptedMasterKey{ Nonce: newEncryptedMasterKey[:24], Ciphertext: newEncryptedMasterKey[24:], KeyVersion: 1, } } // Update encrypted private key if len(newEncryptedPrivateKey) > 24 { user.SecurityData.EncryptedPrivateKey = crypto.EncryptedPrivateKey{ Nonce: newEncryptedPrivateKey[:24], Ciphertext: newEncryptedPrivateKey[24:], } } // Update encrypted recovery key if len(newEncryptedRecoveryKey) > 24 { user.SecurityData.EncryptedRecoveryKey = crypto.EncryptedRecoveryKey{ Nonce: newEncryptedRecoveryKey[:24], Ciphertext: newEncryptedRecoveryKey[24:], } } // Update master key encrypted with recovery key if len(newMasterKeyEncryptedWithRecovery) > 24 { user.SecurityData.MasterKeyEncryptedWithRecoveryKey = crypto.MasterKeyEncryptedWithRecoveryKey{ Nonce: newMasterKeyEncryptedWithRecovery[:24], Ciphertext: newMasterKeyEncryptedWithRecovery[24:], } } // Update user's modified timestamp user.ModifiedAt = time.Now() // Step 3: Save updated user with new credentials (compensate: restore old credentials) // CRITICAL: This must succeed before token deletion to prevent account takeover if err := s.userUpdateUC.Execute(ctx, user); err != nil { s.logger.Error("Failed to update user with new keys", zap.Error(err)) return nil, fmt.Errorf("failed to complete recovery") } // Register compensation: restore old credentials if token deletion fails userCaptured := user oldSecurityDataCaptured := oldSecurityData saga.AddCompensation(func(ctx context.Context) error { s.logger.Warn("compensating: restoring old credentials", zap.String("user_id", userCaptured.ID.String())) // Restore old security data userCaptured.SecurityData = oldSecurityDataCaptured userCaptured.ModifiedAt = time.Now() if err := s.userUpdateUC.Execute(ctx, userCaptured); err != nil { s.logger.Error("Failed to restore old credentials during compensation", zap.String("user_id", userCaptured.ID.String()), zap.Error(err)) return fmt.Errorf("compensation failed: %w", err) } s.logger.Info("old credentials restored successfully during compensation", zap.String("user_id", userCaptured.ID.String())) return nil }) // Step 4: Clear recovery token (one-time use) - MUST succeed to prevent reuse // CRITICAL: If this fails, recovery token could be reused for account takeover tokenKeyCaptured := tokenKey if err := s.cache.Delete(ctx, tokenKeyCaptured); err != nil { s.logger.Error("Failed to delete recovery token - SECURITY RISK", zap.String("token_key", tokenKeyCaptured), zap.Error(err)) // Trigger compensation: Restore old credentials saga.Rollback(ctx) return nil, fmt.Errorf("failed to invalidate recovery token - please contact support") } s.logger.Info("Recovery completion successful", zap.String("email", validation.MaskEmail(email)), zap.String("user_id", user.ID.String())) // Audit log recovery completion s.auditLogger.LogAuth(ctx, auditlog.EventTypeRecoveryCompleted, auditlog.OutcomeSuccess, validation.MaskEmail(email), "", map[string]string{ "user_id": user.ID.String(), }) return &RecoveryCompleteResponseDTO{ Message: "Account recovery completed successfully. You can now log in with your new credentials.", Success: true, }, nil }