monorepo/cloud/maplefile-backend/internal/service/auth/recovery_verify.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
}