monorepo/native/desktop/maplefile/internal/app/app_auth.go

977 lines
36 KiB
Go

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
}