Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
177
cloud/maplefile-backend/internal/service/auth/recovery_verify.go
Normal file
177
cloud/maplefile-backend/internal/service/auth/recovery_verify.go
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue