Email-Based Password Reset - Standard email verification flow
Critical: If you lose your password AND don’t have a recovery phrase set up, your data is permanently unrecoverable. This is an inherent trade-off of true end-to-end encryption.
Human-readable: 24 words from a standard wordlist (e.g., “abandon ability able about above absent absorb…”)
High entropy: 256 bits of randomness
Checksum: Built-in error detection
Offline: Write it down, store in a safe, never needs internet
Industry standard: Used by Bitcoin wallets, hardware security modules
BIP39 (Bitcoin Improvement Proposal 39) is a widely adopted standard for generating mnemonic phrases. It’s battle-tested by the cryptocurrency industry.
// frontend/lib/crypto.tsimport { generateMnemonic, validateMnemonic } from '@scure/bip39'import { wordlist } from '@scure/bip39/wordlists/english.js'/** * Generate a 24-word BIP39 mnemonic phrase * Uses 256 bits of entropy → 24 words */export function generateBIP39Mnemonic(): string { return generateMnemonic(wordlist, 256)}/** * Validate a BIP39 mnemonic phrase */export function validateBIP39Mnemonic(mnemonic: string): boolean { return validateMnemonic(mnemonic, wordlist)}
Frontend: Derive Recovery Key
// frontend/lib/crypto.ts/** * Derive a RecoveryKey from a BIP39 mnemonic using Argon2id. * Uses a separate salt (recovery_salt) from the PIN salt (key_salt). */export async function deriveRecoveryKey( mnemonic: string, recoverySaltHex: string): Promise<CryptoKey> { const salt = hexToBytes(recoverySaltHex) const passwordBytes = utf8ToBytes(mnemonic) const derivedBytes = argon2id(passwordBytes, salt, { t: 3, m: 65536, // 64 MB p: 4, dkLen: 32, }) return crypto.subtle.importKey( 'raw', toArrayBuffer(derivedBytes), { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] )}
Frontend: Generate Recovery Blob
// frontend/lib/crypto.ts/** * Generate recovery_blob: encrypt the raw UserKey bytes with RecoveryKey. * * IMPORTANT: The UserKey must be extractable for this to work. * During recovery setup, we re-derive the UserKey as extractable. */export async function generateRecoveryBlob( userKeyRaw: Uint8Array, recoveryKey: CryptoKey): Promise<string> { const iv = crypto.getRandomValues(new Uint8Array(12)) const encrypted = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, recoveryKey, toArrayBuffer(userKeyRaw) ) const combined = new Uint8Array(iv.length + encrypted.byteLength) combined.set(iv) combined.set(new Uint8Array(encrypted), iv.length) return bytesToBase64(combined)}
Backend: Save Recovery Blob
// backend/controllers/auth/auth-controller.tsexport const saveRecoveryBlob = asyncHandler(async (req: Request, res: Response) => { const { recoveryBlob, recoverySalt } = req.body if (!recoveryBlob || typeof recoveryBlob !== 'string') { throw new AppError('Recovery blob is required', 400) } if (!recoverySalt || typeof recoverySalt !== 'string' || recoverySalt.length !== 64) { throw new AppError('Recovery salt is required (64-char hex)', 400) } await db.query( `UPDATE users SET recovery_blob = ?, recovery_salt = ?, bip39_verified = TRUE WHERE id = ?`, [recoveryBlob, recoverySalt, req.user!.id] ) res.status(200).json({ success: true })})
Backend: Recover with BIP39
// backend/controllers/auth/auth-controller.tsexport const recoverWithBip39 = asyncHandler(async (req: Request, res: Response) => { const { newKeySalt, verificationBlob, recoveryBlob, reEncryptedKeys } = req.body if (!newKeySalt || !verificationBlob || !recoveryBlob || !reEncryptedKeys?.length) { throw new AppError('Missing required fields for recovery', 400) } const connection = await db.getConnection() try { await connection.beginTransaction() // Update user: new key_salt, verification_blob, recovery_blob, reset attempts await connection.query( `UPDATE users SET key_salt = ?, verification_blob = ?, recovery_blob = ?, pin_attempts = 0, pin_locked_until = NULL, bip39_attempts = 0, bip39_locked_until = NULL, updated_at = NOW() WHERE id = ?`, [newKeySalt, verificationBlob, recoveryBlob, req.user!.id] ) // Update all account keys for (const key of reEncryptedKeys) { await connection.query( `UPDATE account_keys SET encrypted_key = ?, key_version = key_version + 1 WHERE account_id = ? AND user_id = ?`, [key.encryptedKey, key.accountId, req.user!.id] ) } await connection.commit() // Clear cookies to force re-login with new PIN res.clearCookie('accessToken', { path: '/' }) res.clearCookie('refreshToken', { path: '/' }) res.clearCookie('csrfToken', { path: '/' }) res.status(200).json({ success: true, message: 'Recovery successful. Please log in with your new PIN.', }) } catch (error) { await connection.rollback() throw error } finally { connection.release() }})
Failed Attempts Lockout: After 5 failed BIP39 recovery attempts, the account is locked for 30 minutes to prevent brute force attacks.
// backend/repositories/auth/user-repository.tsexport async function checkBip39Attempt(userId: string): Promise<void> { const [rows] = await db.query<UserRow[]>( `SELECT bip39_attempts, bip39_locked_until FROM users WHERE id = ?`, [userId] ) const user = rows[0] if (!user) throw new AppError('User not found', 404) // Check lockout if (user.bip39_locked_until && new Date(user.bip39_locked_until) > new Date()) { const minutesLeft = Math.ceil( (new Date(user.bip39_locked_until).getTime() - Date.now()) / 60000 ) throw new AppError( `Too many failed attempts. Try again in ${minutesLeft} minutes.`, 429 ) } // Reset attempts if lockout expired if (user.bip39_locked_until && new Date(user.bip39_locked_until) <= new Date()) { await db.query( `UPDATE users SET bip39_attempts = 0, bip39_locked_until = NULL WHERE id = ?`, [userId] ) }}
Go to /setup-recovery after registration and generate your 24-word phrase.
2
Write It Down
Never store your recovery phrase digitally. Write it on paper and keep it in a safe place.
3
Test Recovery
Verify you can recover your account using the phrase before relying on it.
4
Multiple Copies
Keep multiple copies in secure locations (e.g., home safe, safety deposit box).
Pro tip: Split your recovery phrase across two locations. Store words 1-12 in one place and words 13-24 in another. This way, a single breach doesn’t compromise your account.