monorepo/cloud/maplefile-backend/internal/service/auth/recovery_complete.go

251 lines
9.2 KiB
Go

// 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
}