251 lines
9.2 KiB
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
|
|
}
|