177 lines
7.4 KiB
Go
177 lines
7.4 KiB
Go
// 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
|
|
}
|