package app import ( "context" "crypto/rand" "encoding/base64" "fmt" "time" "github.com/tyler-smith/go-bip39" "go.uber.org/zap" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/maplefile/client" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/maplefile/e2ee" "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/inputvalidation" "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/ratelimiter" "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/utils" ) // RequestOTT requests a one-time token for login func (a *Application) RequestOTT(email string) error { // Validate input if err := inputvalidation.ValidateEmail(email); err != nil { return err } // Check rate limit before making request // Note: We do NOT reset on success here - the rate limit prevents spamming // the "request OTT" button. Users should wait between OTT requests. if err := a.rateLimiter.Check(ratelimiter.OpRequestOTT, email); err != nil { a.logger.Warn("OTT request rate limited", zap.String("email", utils.MaskEmail(email)), zap.Error(err)) return err } return a.authService.RequestOTT(a.ctx, email) } // Logout logs out the current user and deletes all local data (default behavior for security). // Use LogoutWithOptions for more control over local data deletion. func (a *Application) Logout() error { return a.LogoutWithOptions(true) // Default to deleting local data for security } // LogoutWithOptions logs out the current user with control over local data deletion. // If deleteLocalData is true, all locally cached files and metadata will be permanently deleted. // If deleteLocalData is false, local data is preserved for faster login next time. func (a *Application) LogoutWithOptions(deleteLocalData bool) error { // Get session before clearing session, _ := a.authService.GetCurrentSession(a.ctx) var userEmail string if session != nil { userEmail = session.Email } // Stop token manager first stopCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if err := a.tokenManager.Stop(stopCtx); err != nil { a.logger.Error("Failed to stop token manager during logout", zap.Error(err)) // Continue with logout even if token manager stop failed } // Clear stored password from RAM if session != nil { if err := a.passwordStore.ClearPassword(session.Email); err != nil { a.logger.Error("Failed to clear stored password", zap.Error(err)) } else { a.logger.Info("Password cleared from secure RAM", zap.String("email", utils.MaskEmail(session.Email))) } // Clear cached master key from memory (if it exists) if a.keyCache.HasMasterKey(session.Email) { if err := a.keyCache.ClearMasterKey(session.Email); err != nil { a.logger.Warn("Failed to clear cached master key", zap.Error(err)) } else { a.logger.Info("Cached master key cleared from secure memory", zap.String("email", utils.MaskEmail(session.Email))) } } else { a.logger.Debug("No cached master key to clear (expected after app restart)", zap.String("email", utils.MaskEmail(session.Email))) } } // Close search index if err := a.searchService.Close(); err != nil { a.logger.Error("Failed to close search index during logout", zap.Error(err)) // Continue with logout even if search cleanup fails } else { a.logger.Info("Search index closed") } // Handle local data based on user preference if deleteLocalData && userEmail != "" { // Delete all local data permanently if err := a.storageManager.DeleteUserData(userEmail); err != nil { a.logger.Error("Failed to delete local user data", zap.Error(err)) // Continue with logout even if deletion fails } else { a.logger.Info("All local user data deleted", zap.String("email", utils.MaskEmail(userEmail))) } } else { // Just cleanup storage connections (keep data on disk) a.storageManager.Cleanup() a.logger.Info("User storage connections closed, local data preserved") } // Clear session return a.authService.Logout(a.ctx) } // GetLocalDataSize returns the size of locally stored data for the current user in bytes. // This can be used to show the user how much data will be deleted on logout. func (a *Application) GetLocalDataSize() (int64, error) { session, err := a.authService.GetCurrentSession(a.ctx) if err != nil || session == nil { return 0, nil } size, err := a.storageManager.GetUserDataSize(session.Email) if err != nil { a.logger.Warn("Failed to get local data size", zap.Error(err)) return 0, err } return size, nil } // IsLoggedIn checks if a user is logged in func (a *Application) IsLoggedIn() (bool, error) { return a.authService.IsLoggedIn(a.ctx) } // Register creates a new user account func (a *Application) Register(input *client.RegisterInput) error { // Validate input if err := inputvalidation.ValidateEmail(input.Email); err != nil { return err } if err := inputvalidation.ValidateDisplayName(input.FirstName, "first name"); err != nil { return err } if err := inputvalidation.ValidateDisplayName(input.LastName, "last name"); err != nil { return err } // Note: Password is not sent directly in RegisterInput - it's used client-side // to derive encryption keys. The encrypted master key and salt are validated // by their presence and format on the server side. // Check rate limit before making request // Note: We do NOT reset on success - registration is a one-time operation // and keeping the rate limit prevents re-registration spam attempts. if err := a.rateLimiter.Check(ratelimiter.OpRegister, input.Email); err != nil { a.logger.Warn("Registration rate limited", zap.String("email", utils.MaskEmail(input.Email)), zap.Error(err)) return err } return a.authService.Register(a.ctx, input) } // VerifyEmail verifies the email with the verification code func (a *Application) VerifyEmail(email, code string) error { // Validate input if err := inputvalidation.ValidateEmail(email); err != nil { return err } if err := inputvalidation.ValidateOTT(code); err != nil { return err } // Check rate limit before making request if err := a.rateLimiter.Check(ratelimiter.OpVerifyEmail, email); err != nil { a.logger.Warn("Email verification rate limited", zap.String("email", utils.MaskEmail(email)), zap.Error(err)) return err } input := &client.VerifyEmailInput{ Email: email, Code: code, } err := a.authService.VerifyEmail(a.ctx, input) if err == nil { // Reset rate limit on success a.rateLimiter.Reset(ratelimiter.OpVerifyEmail, email) } return err } // VerifyOTTResponse contains the OTT verification response with encrypted challenge type VerifyOTTResponse struct { Message string `json:"message"` ChallengeID string `json:"challengeId"` EncryptedChallenge string `json:"encryptedChallenge"` Salt string `json:"salt"` EncryptedMasterKey string `json:"encryptedMasterKey"` EncryptedPrivateKey string `json:"encryptedPrivateKey"` PublicKey string `json:"publicKey"` // KDFAlgorithm specifies which key derivation algorithm to use. // Value: "PBKDF2-SHA256" KDFAlgorithm string `json:"kdfAlgorithm"` } // VerifyOTT verifies the one-time token and returns the encrypted challenge func (a *Application) VerifyOTT(email, ott string) (*VerifyOTTResponse, error) { // Validate input if err := inputvalidation.ValidateEmail(email); err != nil { return nil, err } if err := inputvalidation.ValidateOTT(ott); err != nil { return nil, err } // Check rate limit before making request if err := a.rateLimiter.Check(ratelimiter.OpVerifyOTT, email); err != nil { a.logger.Warn("OTT verification rate limited", zap.String("email", utils.MaskEmail(email)), zap.Error(err)) return nil, err } resp, err := a.authService.VerifyOTT(a.ctx, email, ott) if err != nil { a.logger.Error("OTT verification failed", zap.Error(err)) return nil, err } // Reset rate limit on success a.rateLimiter.Reset(ratelimiter.OpVerifyOTT, email) // Get KDF algorithm from response, default to PBKDF2-SHA256 kdfAlgorithm := resp.KDFAlgorithm if kdfAlgorithm == "" { kdfAlgorithm = e2ee.PBKDF2Algorithm } return &VerifyOTTResponse{ Message: resp.Message, ChallengeID: resp.ChallengeID, EncryptedChallenge: resp.EncryptedChallenge, Salt: resp.Salt, EncryptedMasterKey: resp.EncryptedMasterKey, EncryptedPrivateKey: resp.EncryptedPrivateKey, PublicKey: resp.PublicKey, KDFAlgorithm: kdfAlgorithm, }, nil } // CompleteLoginInput contains the data needed to complete login type CompleteLoginInput struct { Email string `json:"email"` ChallengeID string `json:"challengeId"` DecryptedData string `json:"decryptedData"` Password string `json:"password"` // Encrypted user data for future password verification Salt string `json:"salt"` EncryptedMasterKey string `json:"encryptedMasterKey"` EncryptedPrivateKey string `json:"encryptedPrivateKey"` PublicKey string `json:"publicKey"` // KDFAlgorithm specifies which key derivation algorithm to use. // Value: "PBKDF2-SHA256" KDFAlgorithm string `json:"kdfAlgorithm"` } // CompleteLogin completes the login process with the decrypted challenge func (a *Application) CompleteLogin(input *CompleteLoginInput) error { // Validate input if err := inputvalidation.ValidateEmail(input.Email); err != nil { return err } if err := inputvalidation.ValidatePassword(input.Password); err != nil { return err } if input.ChallengeID == "" { return fmt.Errorf("challenge ID is required") } if input.DecryptedData == "" { return fmt.Errorf("decrypted data is required") } // Check rate limit before making request if err := a.rateLimiter.Check(ratelimiter.OpCompleteLogin, input.Email); err != nil { a.logger.Warn("Login completion rate limited", zap.String("email", utils.MaskEmail(input.Email)), zap.Error(err)) return err } clientInput := &client.CompleteLoginInput{ Email: input.Email, ChallengeID: input.ChallengeID, DecryptedData: input.DecryptedData, } _, err := a.authService.CompleteLogin(a.ctx, clientInput) if err != nil { a.logger.Error("Login completion failed", zap.Error(err)) return err } // Reset all rate limits for this user on successful login a.rateLimiter.ResetAll(input.Email) // Store encrypted user data in session for future password verification session, err := a.authService.GetCurrentSession(a.ctx) if err == nil && session != nil { session.Salt = input.Salt session.EncryptedMasterKey = input.EncryptedMasterKey session.EncryptedPrivateKey = input.EncryptedPrivateKey session.PublicKey = input.PublicKey // Store KDF algorithm so VerifyPassword knows which algorithm to use session.KDFAlgorithm = input.KDFAlgorithm if session.KDFAlgorithm == "" { session.KDFAlgorithm = e2ee.PBKDF2Algorithm } // Update session with encrypted data if err := a.authService.UpdateSession(a.ctx, session); err != nil { a.logger.Warn("Failed to update session with encrypted data", zap.Error(err)) // Continue anyway - password storage will still work } else { a.logger.Info("Encrypted user data stored in session for password verification") } } // Store password in secure RAM if err := a.passwordStore.StorePassword(input.Email, input.Password); err != nil { a.logger.Error("Failed to store password in RAM", zap.Error(err)) // Don't fail login if password storage fails } else { a.logger.Info("Password stored securely in RAM for E2EE operations", zap.String("email", utils.MaskEmail(input.Email))) } // Cache master key for session to avoid re-decrypting for every file operation if input.Salt != "" && input.EncryptedMasterKey != "" && input.Password != "" { kdfAlgorithm := input.KDFAlgorithm if kdfAlgorithm == "" { kdfAlgorithm = e2ee.PBKDF2Algorithm } if err := a.cacheMasterKeyFromPassword(input.Email, input.Password, input.Salt, input.EncryptedMasterKey, kdfAlgorithm); err != nil { a.logger.Warn("Failed to cache master key during login", zap.Error(err)) // Continue anyway - user can still use the app, just slower } } a.logger.Info("User logged in successfully", zap.String("email", utils.MaskEmail(input.Email))) // Initialize user-specific storage for the logged-in user if err := a.storageManager.InitializeForUser(input.Email); err != nil { a.logger.Error("Failed to initialize user storage", zap.Error(err)) // Don't fail login - user can still use cloud features, just not local storage } else { a.logger.Info("User storage initialized", zap.String("email", utils.MaskEmail(input.Email))) } // Initialize search index for the logged-in user if err := a.searchService.Initialize(a.ctx, input.Email); err != nil { a.logger.Error("Failed to initialize search index", zap.Error(err)) // Don't fail login if search initialization fails - it's not critical // The app can still function without search } else { a.logger.Info("Search index initialized", zap.String("email", utils.MaskEmail(input.Email))) // Rebuild search index from local data in the background userEmail := input.Email // Capture email before goroutine go func() { if err := a.rebuildSearchIndexForUser(userEmail); err != nil { a.logger.Warn("Failed to rebuild search index after login", zap.Error(err)) } }() } // Start token manager for automatic token refresh a.tokenManager.Start() a.logger.Info("Token manager started for new session") return nil } // DecryptLoginChallenge decrypts the login challenge using the user's password. // The kdfAlgorithm parameter specifies which key derivation function to use. // If kdfAlgorithm is empty, it defaults to "PBKDF2-SHA256". func (a *Application) DecryptLoginChallenge(password, saltBase64, encryptedMasterKeyBase64, encryptedChallengeBase64, encryptedPrivateKeyBase64, publicKeyBase64, kdfAlgorithm string) (string, error) { // Default to PBKDF2-SHA256 if kdfAlgorithm == "" { kdfAlgorithm = e2ee.PBKDF2Algorithm } a.logger.Debug("Decrypting login challenge", zap.String("kdf_algorithm", kdfAlgorithm)) // Decode base64 inputs salt, err := base64.StdEncoding.DecodeString(saltBase64) if err != nil { a.logger.Error("Failed to decode salt", zap.Error(err)) return "", fmt.Errorf("invalid salt encoding: %w", err) } encryptedChallenge, err := base64.StdEncoding.DecodeString(encryptedChallengeBase64) if err != nil { a.logger.Error("Failed to decode encrypted challenge", zap.Error(err)) return "", fmt.Errorf("invalid challenge encoding: %w", err) } publicKey, err := base64.StdEncoding.DecodeString(publicKeyBase64) if err != nil { a.logger.Error("Failed to decode public key", zap.Error(err)) return "", fmt.Errorf("invalid public key encoding: %w", err) } // Decode encrypted private key encryptedPrivateKeyCombined, err := base64.StdEncoding.DecodeString(encryptedPrivateKeyBase64) if err != nil { a.logger.Error("Failed to decode encrypted private key", zap.Error(err)) return "", fmt.Errorf("invalid encrypted private key encoding: %w", err) } // Decode encrypted master key encryptedMasterKeyCombined, err := base64.StdEncoding.DecodeString(encryptedMasterKeyBase64) if err != nil { a.logger.Error("Failed to decode encrypted master key", zap.Error(err)) return "", fmt.Errorf("invalid encrypted master key encoding: %w", err) } // 1. Derive KEK from password and salt using PBKDF2-SHA256 keychain, err := e2ee.NewSecureKeyChainWithAlgorithm(password, salt, kdfAlgorithm) if err != nil { a.logger.Error("Failed to create secure keychain", zap.Error(err), zap.String("kdf_algorithm", kdfAlgorithm)) return "", fmt.Errorf("failed to derive key from password: %w", err) } defer keychain.Clear() // 2. Decrypt master key with KEK into protected memory // Auto-detect nonce size: web frontend uses 24-byte nonces (XSalsa20), native uses 12-byte (ChaCha20) masterKeyNonce, masterKeyCiphertext, err := e2ee.SplitNonceAndCiphertextSecretBox(encryptedMasterKeyCombined) if err != nil { a.logger.Error("Failed to split encrypted master key", zap.Error(err)) return "", fmt.Errorf("invalid encrypted master key format: %w", err) } encryptedMasterKeyStruct := &e2ee.EncryptedKey{ Ciphertext: masterKeyCiphertext, Nonce: masterKeyNonce, } masterKey, err := keychain.DecryptMasterKeySecure(encryptedMasterKeyStruct) if err != nil { a.logger.Error("Failed to decrypt master key", zap.Error(err), zap.String("kdf_algorithm", kdfAlgorithm)) return "", fmt.Errorf("failed to decrypt master key (wrong password?): %w", err) } defer masterKey.Destroy() // 3. Decrypt private key with master key into protected memory // Auto-detect nonce size based on the encrypted data privateKeyNonce, privateKeyCiphertext, err := e2ee.SplitNonceAndCiphertextSecretBox(encryptedPrivateKeyCombined) if err != nil { a.logger.Error("Failed to split encrypted private key", zap.Error(err)) return "", fmt.Errorf("invalid encrypted private key format: %w", err) } encryptedPrivateKeyStruct := &e2ee.EncryptedKey{ Ciphertext: privateKeyCiphertext, Nonce: privateKeyNonce, } privateKey, err := e2ee.DecryptPrivateKeySecure(encryptedPrivateKeyStruct, masterKey) if err != nil { a.logger.Error("Failed to decrypt private key", zap.Error(err)) return "", fmt.Errorf("failed to decrypt private key: %w", err) } defer privateKey.Destroy() // 4. Decrypt the challenge using the private key (NaCl anonymous box) decryptedChallenge, err := e2ee.DecryptAnonymousBox(encryptedChallenge, publicKey, privateKey.Bytes()) if err != nil { a.logger.Error("Failed to decrypt challenge", zap.Error(err)) return "", fmt.Errorf("failed to decrypt login challenge: %w", err) } // Convert decrypted challenge to base64 for sending to server decryptedChallengeBase64 := base64.StdEncoding.EncodeToString(decryptedChallenge) a.logger.Info("Successfully decrypted login challenge") return decryptedChallengeBase64, nil } // RegistrationKeys contains all the E2EE keys needed for registration type RegistrationKeys struct { Salt string `json:"salt"` EncryptedMasterKey string `json:"encryptedMasterKey"` PublicKey string `json:"publicKey"` EncryptedPrivateKey string `json:"encryptedPrivateKey"` EncryptedRecoveryKey string `json:"encryptedRecoveryKey"` MasterKeyEncryptedWithRecoveryKey string `json:"masterKeyEncryptedWithRecoveryKey"` // RecoveryMnemonic is the 12-word BIP39 mnemonic phrase that must be shown to the user // The user MUST save this phrase securely - it's their only way to recover their account RecoveryMnemonic string `json:"recoveryMnemonic"` } // RecoveryInitiateResponse contains the response from initiating account recovery type RecoveryInitiateResponse struct { Message string `json:"message"` SessionID string `json:"sessionId"` EncryptedChallenge string `json:"encryptedChallenge"` } // InitiateRecovery starts the account recovery process for the given email func (a *Application) InitiateRecovery(email string) (*RecoveryInitiateResponse, error) { // Validate input if err := inputvalidation.ValidateEmail(email); err != nil { return nil, err } // Check rate limit before making request if err := a.rateLimiter.Check(ratelimiter.OpRequestOTT, email); err != nil { a.logger.Warn("Recovery initiation rate limited", zap.String("email", utils.MaskEmail(email)), zap.Error(err)) return nil, err } resp, err := a.authService.InitiateRecovery(a.ctx, email, "recovery_key") if err != nil { a.logger.Error("Recovery initiation failed", zap.Error(err)) return nil, err } a.logger.Info("Recovery initiated successfully", zap.String("email", utils.MaskEmail(email))) return &RecoveryInitiateResponse{ Message: resp.Message, SessionID: resp.SessionID, EncryptedChallenge: resp.EncryptedChallenge, }, nil } // DecryptRecoveryChallengeInput contains the data needed to process recovery challenge type DecryptRecoveryChallengeInput struct { RecoveryMnemonic string `json:"recoveryMnemonic"` EncryptedChallenge string `json:"encryptedChallenge"` } // DecryptRecoveryChallengeResult contains the result of processing recovery challenge type DecryptRecoveryChallengeResult struct { DecryptedChallenge string `json:"decryptedChallenge"` IsValid bool `json:"isValid"` } // DecryptRecoveryChallenge validates the recovery mnemonic and processes the challenge. // Note: The backend currently sends an unencrypted challenge (base64-encoded plaintext). // This function validates the recovery phrase format and passes through the challenge. // When the backend implements proper encryption, this function will decrypt the challenge. func (a *Application) DecryptRecoveryChallenge(input *DecryptRecoveryChallengeInput) (*DecryptRecoveryChallengeResult, error) { // Validate recovery mnemonic (must be 12 words) if input.RecoveryMnemonic == "" { return nil, fmt.Errorf("recovery mnemonic is required") } // Validate the mnemonic is a valid BIP39 phrase if !bip39.IsMnemonicValid(input.RecoveryMnemonic) { a.logger.Warn("Invalid recovery mnemonic format") return nil, fmt.Errorf("invalid recovery phrase: must be 12 valid BIP39 words") } // Count words to ensure we have exactly 12 words := len(splitMnemonic(input.RecoveryMnemonic)) if words != 12 { return nil, fmt.Errorf("invalid recovery phrase: must be exactly 12 words, got %d", words) } // Validate the encrypted challenge is present if input.EncryptedChallenge == "" { return nil, fmt.Errorf("encrypted challenge is required") } // Derive recovery key from mnemonic to validate it's a valid recovery phrase // This also prepares for future decryption when backend implements encryption seed := bip39.NewSeed(input.RecoveryMnemonic, "") recoveryKey := seed[:32] a.logger.Debug("Recovery key derived successfully", zap.Int("key_length", len(recoveryKey)), zap.Int("word_count", words)) // TEMPORARY WORKAROUND: Backend currently sends base64-encoded plaintext challenge // instead of encrypted challenge. See backend TODO in recovery_initiate.go:108-113 // Until backend implements proper encryption, we just validate and pass through. // Decode the challenge to validate it's valid base64 challengeBytes, err := base64.StdEncoding.DecodeString(input.EncryptedChallenge) if err != nil { a.logger.Error("Failed to decode challenge", zap.Error(err)) return nil, fmt.Errorf("invalid challenge format: %w", err) } // Re-encode to base64 for sending to backend decryptedChallengeBase64 := base64.StdEncoding.EncodeToString(challengeBytes) a.logger.Info("Recovery challenge processed successfully (backend workaround active)") return &DecryptRecoveryChallengeResult{ DecryptedChallenge: decryptedChallengeBase64, IsValid: true, }, nil } // splitMnemonic splits a mnemonic phrase into words func splitMnemonic(mnemonic string) []string { var words []string for _, word := range splitByWhitespace(mnemonic) { if word != "" { words = append(words, word) } } return words } // splitByWhitespace splits a string by whitespace characters func splitByWhitespace(s string) []string { return splitString(s) } // splitString splits a string into words by spaces func splitString(s string) []string { var result []string word := "" for _, r := range s { if r == ' ' || r == '\t' || r == '\n' || r == '\r' { if word != "" { result = append(result, word) word = "" } } else { word += string(r) } } if word != "" { result = append(result, word) } return result } // RecoveryVerifyResponse contains the response from verifying recovery type RecoveryVerifyResponse struct { Message string `json:"message"` RecoveryToken string `json:"recoveryToken"` CanResetCredentials bool `json:"canResetCredentials"` } // VerifyRecovery verifies the recovery challenge with the server func (a *Application) VerifyRecovery(sessionID, decryptedChallenge string) (*RecoveryVerifyResponse, error) { if sessionID == "" { return nil, fmt.Errorf("session ID is required") } if decryptedChallenge == "" { return nil, fmt.Errorf("decrypted challenge is required") } input := &client.RecoveryVerifyInput{ SessionID: sessionID, DecryptedChallenge: decryptedChallenge, } resp, err := a.authService.VerifyRecovery(a.ctx, input) if err != nil { a.logger.Error("Recovery verification failed", zap.Error(err)) return nil, err } a.logger.Info("Recovery verification successful") return &RecoveryVerifyResponse{ Message: resp.Message, RecoveryToken: resp.RecoveryToken, CanResetCredentials: resp.CanResetCredentials, }, nil } // CompleteRecoveryInput contains the data needed to complete account recovery type CompleteRecoveryInput struct { RecoveryToken string `json:"recoveryToken"` RecoveryMnemonic string `json:"recoveryMnemonic"` NewPassword string `json:"newPassword"` } // CompleteRecoveryResponse contains the response from completing recovery type CompleteRecoveryResponse struct { Message string `json:"message"` Success bool `json:"success"` } // CompleteRecovery completes the account recovery by re-encrypting keys with a new password. // This function: // 1. Validates the recovery mnemonic // 2. Derives the recovery key from the mnemonic // 3. Generates new encryption keys with the new password // 4. Sends the new encrypted keys to the server func (a *Application) CompleteRecovery(input *CompleteRecoveryInput) (*CompleteRecoveryResponse, error) { // Validate inputs if input.RecoveryToken == "" { return nil, fmt.Errorf("recovery token is required") } if input.RecoveryMnemonic == "" { return nil, fmt.Errorf("recovery mnemonic is required") } if err := inputvalidation.ValidatePassword(input.NewPassword); err != nil { return nil, err } // Validate the mnemonic is a valid BIP39 phrase if !bip39.IsMnemonicValid(input.RecoveryMnemonic) { return nil, fmt.Errorf("invalid recovery phrase: must be 12 valid BIP39 words") } // Count words to ensure we have exactly 12 words := len(splitMnemonic(input.RecoveryMnemonic)) if words != 12 { return nil, fmt.Errorf("invalid recovery phrase: must be exactly 12 words, got %d", words) } a.logger.Info("Starting recovery completion - generating new encryption keys") // 1. Derive recovery key from mnemonic seed := bip39.NewSeed(input.RecoveryMnemonic, "") recoveryKeyBytes := seed[:32] recoveryKey, err := e2ee.NewSecureBuffer(recoveryKeyBytes) if err != nil { e2ee.ClearBytes(recoveryKeyBytes) return nil, fmt.Errorf("failed to create secure buffer for recovery key: %w", err) } defer recoveryKey.Destroy() e2ee.ClearBytes(recoveryKeyBytes) // 2. Generate new salt for the new password newSalt, err := e2ee.GenerateSalt() if err != nil { return nil, fmt.Errorf("failed to generate new salt: %w", err) } // 3. Create new keychain with PBKDF2-SHA256 (for web frontend compatibility) newKeychain, err := e2ee.NewSecureKeyChainWithAlgorithm(input.NewPassword, newSalt, e2ee.PBKDF2Algorithm) if err != nil { return nil, fmt.Errorf("failed to create new keychain: %w", err) } defer newKeychain.Clear() // 4. Generate new master key masterKeyBytes, err := e2ee.GenerateMasterKey() if err != nil { return nil, fmt.Errorf("failed to generate new master key: %w", err) } masterKey, err := e2ee.NewSecureBuffer(masterKeyBytes) if err != nil { e2ee.ClearBytes(masterKeyBytes) return nil, fmt.Errorf("failed to create secure buffer for master key: %w", err) } defer masterKey.Destroy() e2ee.ClearBytes(masterKeyBytes) // 5. Encrypt master key with new KEK encryptedMasterKey, err := newKeychain.EncryptMasterKeySecretBox(masterKey.Bytes()) if err != nil { return nil, fmt.Errorf("failed to encrypt master key: %w", err) } // 6. Generate new keypair newPublicKey, privateKeyBytes, err := e2ee.GenerateKeyPair() if err != nil { return nil, fmt.Errorf("failed to generate new keypair: %w", err) } privateKey, err := e2ee.NewSecureBuffer(privateKeyBytes) if err != nil { e2ee.ClearBytes(privateKeyBytes) return nil, fmt.Errorf("failed to create secure buffer for private key: %w", err) } defer privateKey.Destroy() e2ee.ClearBytes(privateKeyBytes) // 7. Encrypt private key with master key encryptedPrivateKey, err := e2ee.EncryptPrivateKeySecretBox(privateKey.Bytes(), masterKey.Bytes()) if err != nil { return nil, fmt.Errorf("failed to encrypt private key: %w", err) } // 8. Encrypt recovery key with master key encryptedRecoveryKey, err := e2ee.EncryptRecoveryKeySecretBox(recoveryKey.Bytes(), masterKey.Bytes()) if err != nil { return nil, fmt.Errorf("failed to encrypt recovery key: %w", err) } // 9. Encrypt master key with recovery key (for future recovery) masterKeyEncryptedWithRecoveryKey, err := e2ee.EncryptMasterKeyWithRecoveryKeySecretBox(masterKey.Bytes(), recoveryKey.Bytes()) if err != nil { return nil, fmt.Errorf("failed to encrypt master key with recovery key: %w", err) } // 10. Convert all keys to base64 for transport newSaltBase64 := base64.StdEncoding.EncodeToString(newSalt) newPublicKeyBase64 := base64.StdEncoding.EncodeToString(newPublicKey) newEncryptedMasterKeyBase64 := base64.StdEncoding.EncodeToString( e2ee.CombineNonceAndCiphertext(encryptedMasterKey.Nonce, encryptedMasterKey.Ciphertext), ) newEncryptedPrivateKeyBase64 := base64.StdEncoding.EncodeToString( e2ee.CombineNonceAndCiphertext(encryptedPrivateKey.Nonce, encryptedPrivateKey.Ciphertext), ) newEncryptedRecoveryKeyBase64 := base64.StdEncoding.EncodeToString( e2ee.CombineNonceAndCiphertext(encryptedRecoveryKey.Nonce, encryptedRecoveryKey.Ciphertext), ) newMasterKeyEncryptedWithRecoveryKeyBase64 := base64.StdEncoding.EncodeToString( e2ee.CombineNonceAndCiphertext(masterKeyEncryptedWithRecoveryKey.Nonce, masterKeyEncryptedWithRecoveryKey.Ciphertext), ) // 11. Call API to complete recovery apiInput := &client.RecoveryCompleteInput{ RecoveryToken: input.RecoveryToken, NewSalt: newSaltBase64, NewPublicKey: newPublicKeyBase64, NewEncryptedMasterKey: newEncryptedMasterKeyBase64, NewEncryptedPrivateKey: newEncryptedPrivateKeyBase64, NewEncryptedRecoveryKey: newEncryptedRecoveryKeyBase64, NewMasterKeyEncryptedWithRecoveryKey: newMasterKeyEncryptedWithRecoveryKeyBase64, } resp, err := a.authService.CompleteRecovery(a.ctx, apiInput) if err != nil { a.logger.Error("Recovery completion failed", zap.Error(err)) return nil, err } a.logger.Info("Recovery completed successfully - new encryption keys set") return &CompleteRecoveryResponse{ Message: resp.Message, Success: resp.Success, }, nil } // GenerateRegistrationKeys generates all E2EE keys needed for user registration. // This function uses PBKDF2-SHA256 for key derivation and XSalsa20-Poly1305 (SecretBox) // for symmetric encryption to ensure compatibility with the web frontend. func (a *Application) GenerateRegistrationKeys(password string) (*RegistrationKeys, error) { // 1. Generate salt (16 bytes for PBKDF2) salt, err := e2ee.GenerateSalt() if err != nil { a.logger.Error("Failed to generate salt", zap.Error(err)) return nil, err } // 2. Create secure keychain using PBKDF2-SHA256 (compatible with web frontend) // This derives KEK from password + salt using PBKDF2-SHA256 with 100,000 iterations keychain, err := e2ee.NewSecureKeyChainWithAlgorithm(password, salt, e2ee.PBKDF2Algorithm) if err != nil { a.logger.Error("Failed to create secure keychain", zap.Error(err)) return nil, err } defer keychain.Clear() // Clear sensitive data when done // 3. Generate master key in protected memory masterKeyBytes, err := e2ee.GenerateMasterKey() if err != nil { a.logger.Error("Failed to generate master key", zap.Error(err)) return nil, err } masterKey, err := e2ee.NewSecureBuffer(masterKeyBytes) if err != nil { e2ee.ClearBytes(masterKeyBytes) a.logger.Error("Failed to create secure buffer for master key", zap.Error(err)) return nil, err } defer masterKey.Destroy() e2ee.ClearBytes(masterKeyBytes) // 4. Encrypt master key with KEK using XSalsa20-Poly1305 (SecretBox) // This produces 24-byte nonces compatible with web frontend's libsodium encryptedMasterKey, err := keychain.EncryptMasterKeySecretBox(masterKey.Bytes()) if err != nil { a.logger.Error("Failed to encrypt master key", zap.Error(err)) return nil, err } // 5. Generate NaCl keypair for asymmetric encryption publicKey, privateKeyBytes, err := e2ee.GenerateKeyPair() if err != nil { a.logger.Error("Failed to generate keypair", zap.Error(err)) return nil, err } privateKey, err := e2ee.NewSecureBuffer(privateKeyBytes) if err != nil { e2ee.ClearBytes(privateKeyBytes) a.logger.Error("Failed to create secure buffer for private key", zap.Error(err)) return nil, err } defer privateKey.Destroy() e2ee.ClearBytes(privateKeyBytes) // 6. Encrypt private key with master key using XSalsa20-Poly1305 (SecretBox) encryptedPrivateKey, err := e2ee.EncryptPrivateKeySecretBox(privateKey.Bytes(), masterKey.Bytes()) if err != nil { a.logger.Error("Failed to encrypt private key", zap.Error(err)) return nil, err } // 7. Generate BIP39 mnemonic (12 words) for account recovery // This matches the web frontend's approach for cross-platform compatibility entropy := make([]byte, 16) // 128 bits = 12 words if _, err := rand.Read(entropy); err != nil { a.logger.Error("Failed to generate entropy for recovery mnemonic", zap.Error(err)) return nil, err } recoveryMnemonic, err := bip39.NewMnemonic(entropy) if err != nil { a.logger.Error("Failed to generate recovery mnemonic", zap.Error(err)) return nil, err } a.logger.Info("Generated 12-word recovery mnemonic") // Convert mnemonic to seed (64 bytes via HMAC-SHA512) then take first 32 bytes // This matches web frontend's mnemonicToRecoveryKey() function seed := bip39.NewSeed(recoveryMnemonic, "") // Empty passphrase like web frontend recoveryKeyBytes := seed[:32] // Use first 32 bytes as recovery key recoveryKey, err := e2ee.NewSecureBuffer(recoveryKeyBytes) if err != nil { e2ee.ClearBytes(recoveryKeyBytes) a.logger.Error("Failed to create secure buffer for recovery key", zap.Error(err)) return nil, err } defer recoveryKey.Destroy() e2ee.ClearBytes(recoveryKeyBytes) // 8. Encrypt recovery key with master key using XSalsa20-Poly1305 (SecretBox) encryptedRecoveryKey, err := e2ee.EncryptRecoveryKeySecretBox(recoveryKey.Bytes(), masterKey.Bytes()) if err != nil { a.logger.Error("Failed to encrypt recovery key", zap.Error(err)) return nil, err } // 9. Encrypt master key with recovery key using XSalsa20-Poly1305 (SecretBox) masterKeyEncryptedWithRecoveryKey, err := e2ee.EncryptMasterKeyWithRecoveryKeySecretBox(masterKey.Bytes(), recoveryKey.Bytes()) if err != nil { a.logger.Error("Failed to encrypt master key with recovery key", zap.Error(err)) return nil, err } // Convert all keys to base64 for transport // Combine nonce and ciphertext for each encrypted key encryptedMasterKeyBase64 := base64.StdEncoding.EncodeToString( e2ee.CombineNonceAndCiphertext(encryptedMasterKey.Nonce, encryptedMasterKey.Ciphertext), ) encryptedPrivateKeyBase64 := base64.StdEncoding.EncodeToString( e2ee.CombineNonceAndCiphertext(encryptedPrivateKey.Nonce, encryptedPrivateKey.Ciphertext), ) encryptedRecoveryKeyBase64 := base64.StdEncoding.EncodeToString( e2ee.CombineNonceAndCiphertext(encryptedRecoveryKey.Nonce, encryptedRecoveryKey.Ciphertext), ) masterKeyEncryptedWithRecoveryKeyBase64 := base64.StdEncoding.EncodeToString( e2ee.CombineNonceAndCiphertext(masterKeyEncryptedWithRecoveryKey.Nonce, masterKeyEncryptedWithRecoveryKey.Ciphertext), ) a.logger.Info("Successfully generated E2EE registration keys using PBKDF2-SHA256 + XSalsa20-Poly1305") return &RegistrationKeys{ Salt: base64.StdEncoding.EncodeToString(salt), EncryptedMasterKey: encryptedMasterKeyBase64, PublicKey: base64.StdEncoding.EncodeToString(publicKey), EncryptedPrivateKey: encryptedPrivateKeyBase64, EncryptedRecoveryKey: encryptedRecoveryKeyBase64, MasterKeyEncryptedWithRecoveryKey: masterKeyEncryptedWithRecoveryKeyBase64, RecoveryMnemonic: recoveryMnemonic, }, nil }