Skip to main content

Overview

With end-to-end encryption, password loss = data loss. To prevent this, Home Account provides two recovery mechanisms:
  1. BIP39 Recovery Phrase (24 words) - Offline, cryptographic recovery
  2. 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.

BIP39 Recovery Phrase

A 24-word mnemonic phrase that encrypts a copy of your Account Keys. It’s generated once and stored securely in your browser during setup.

Why BIP39?

  • 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.

How BIP39 Recovery Works

Setup Flow

Setup (at /setup-recovery):
  |
  1. Generate 24-word mnemonic (256 bits entropy)
  |
  2. Generate recovery_salt (random 256-bit hex)
  |
  3. Derive RecoveryKey = Argon2id(mnemonic, recovery_salt)
  |
  4. Extract raw UserKey bytes (requires re-deriving as extractable)
  |
  5. recovery_blob = encrypt(UserKey, RecoveryKey)
  |
  6. Store in DB: recovery_blob, recovery_salt, bip39_verified = TRUE
  |
  `-> User writes down 24 words (never stored online)

Recovery Flow

Recovery (at /recovery):
  |
  1. User enters 24-word mnemonic
  |
  2. Validate mnemonic (checksum)
  |
  3. Fetch recovery_blob and recovery_salt from server
  |
  4. RecoveryKey = Argon2id(mnemonic, recovery_salt)
  |
  5. UserKey = decrypt(recovery_blob, RecoveryKey)
  |
  6. User sets new PIN/password
  |
  7. Re-encrypt all AccountKeys with new UserKey
  |
  `-> Access restored

Implementation

// frontend/lib/crypto.ts

import { 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/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/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/controllers/auth/auth-controller.ts

export 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/controllers/auth/auth-controller.ts

export 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()
  }
})

Rate Limiting & Security

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.ts

export 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]
    )
  }
}

Email-Based Password Reset

Standard email-based password reset using EmailJS (server-side SDK).

How It Works

1

Request Reset

User enters email at /forgot-password. Server generates a token, hashes it with SHA-256, and stores the hash in the database.
2

Email Sent

Server sends reset link via EmailJS: https://app.example.com/reset-password?token=...&email=...
3

Verify Token

User clicks link. Frontend calls GET /api/auth/reset-info?token=...&email=... to verify token and fetch key_salt and encrypted keys.
4

Re-Encrypt Keys

If user has BIP39 recovery set up, they can decrypt AccountKeys using their recovery phrase. Otherwise, they must start fresh (all data lost).
5

Reset Password

User enters new password. Frontend re-encrypts all AccountKeys with new UserKey (if recovery phrase provided) and sends to server.

Implementation

// backend/controllers/auth/auth-controller.ts

export const forgotPassword = asyncHandler(async (req: Request, res: Response) => {
  const { email } = req.body

  if (!email || typeof email !== 'string') {
    throw new AppError('Email is required', 400)
  }

  // ALWAYS return success (anti-enumeration)
  const [rows] = await db.query<UserRow[]>(
    `SELECT id, name, email, oauth_provider, password_hash FROM users WHERE email = ?`,
    [email.trim().toLowerCase()]
  )

  const user = rows[0]

  if (user) {
    // Don't allow reset for OAuth-only users (they have no password)
    if (user.oauth_provider && user.oauth_provider !== 'local' && !user.password_hash) {
      // Silently skip — don't reveal that the account is OAuth
    } else {
      // Generate token
      const rawToken = crypto.randomBytes(32).toString('hex')
      const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex')
      const expires = new Date(Date.now() + 60 * 60 * 1000) // 1 hour

      // Store hash in DB
      await db.query(
        `UPDATE users SET reset_token_hash = ?, reset_token_expires = ? WHERE id = ?`,
        [tokenHash, expires, user.id]
      )

      // Build reset link
      const appUrl = process.env.FRONTEND_URL || 'http://localhost:3000'
      const resetLink = `${appUrl}/reset-password?token=${rawToken}&email=${encodeURIComponent(user.email)}`

      // Send email (fire and forget to avoid timing leaks)
      sendPasswordResetEmail({
        toEmail: user.email,
        toName: user.name || 'Usuario',
        resetLink,
      }).catch((err) => {
        logger.error('AUTH', 'forgotPassword', 'Failed to send reset email', String(err))
      })
    }
  }

  // Always return same response
  res.status(200).json({
    success: true,
    message: 'Si el email existe, recibirás instrucciones para restablecer tu contraseña.',
  })
})
// backend/controllers/auth/auth-controller.ts

export const getResetInfo = asyncHandler(async (req: Request, res: Response) => {
  const { email, token } = req.query
  if (!email || !token || typeof email !== 'string' || typeof token !== 'string') {
    throw new AppError('Email and token are required', 400)
  }

  const tokenHash = crypto.createHash('sha256').update(token).digest('hex')

  const [rows] = await db.query<UserRow[]>(
    `SELECT id, reset_token_hash, reset_token_expires, key_salt, recovery_blob, recovery_salt
     FROM users
     WHERE email = ? AND reset_token_hash IS NOT NULL`,
    [email.trim().toLowerCase()]
  )

  const user = rows[0]
  if (!user) throw new AppError('Token inválido o expirado', 400)

  const hashMatch = crypto.timingSafeEqual(
    Buffer.from(tokenHash, 'hex'),
    Buffer.from(user.reset_token_hash!, 'hex')
  )
  if (!hashMatch) throw new AppError('Token inválido o expirado', 400)
  if (!user.reset_token_expires || new Date(user.reset_token_expires) < new Date()) {
    throw new AppError('Token expirado. Solicita un nuevo enlace.', 400)
  }

  const encryptedKeys = await AccountKeyRepository.getByUserId(user.id)

  res.status(200).json({
    success: true,
    key_salt: user.key_salt,
    recovery_blob: user.recovery_blob || null,
    recovery_salt: user.recovery_salt || null,
    encrypted_keys: encryptedKeys,
  })
})
// backend/controllers/auth/auth-controller.ts

export const resetPassword = asyncHandler(async (req: Request, res: Response) => {
  const { email, token, newPassword, newKeySalt, newVerificationBlob, reEncryptedKeys, newRecoveryBlob, clearAccountKeys } = req.body

  if (!email || !token || !newPassword) {
    throw new AppError('Email, token, and new password are required', 400)
  }

  if (newPassword.length < 6) {
    throw new AppError('La contraseña debe tener al menos 6 caracteres', 400)
  }

  const tokenHash = crypto.createHash('sha256').update(token).digest('hex')

  const [rows] = await db.query<UserRow[]>(
    `SELECT id, reset_token_hash, reset_token_expires
     FROM users
     WHERE email = ? AND reset_token_hash IS NOT NULL`,
    [email.trim().toLowerCase()]
  )

  const user = rows[0]
  if (!user) throw new AppError('Token inválido o expirado', 400)

  const hashMatch = crypto.timingSafeEqual(
    Buffer.from(tokenHash, 'hex'),
    Buffer.from(user.reset_token_hash!, 'hex')
  )
  if (!hashMatch) throw new AppError('Token inválido o expirado', 400)

  if (!user.reset_token_expires || new Date(user.reset_token_expires) < new Date()) {
    await db.query(
      `UPDATE users SET reset_token_hash = NULL, reset_token_expires = NULL WHERE id = ?`,
      [user.id]
    )
    throw new AppError('Token expirado. Solicita un nuevo enlace.', 400)
  }

  const hashedPassword = await bcrypt.hash(newPassword, SALT_ROUNDS)

  const connection = await db.getConnection()
  try {
    await connection.beginTransaction()

    // COALESCE: only overwrite crypto fields if new values are provided
    await connection.query(
      `UPDATE users
       SET password_hash = ?,
           key_salt = COALESCE(?, key_salt),
           verification_blob = COALESCE(?, verification_blob),
           recovery_blob = COALESCE(?, recovery_blob),
           reset_token_hash = NULL,
           reset_token_expires = NULL,
           updated_at = NOW()
       WHERE id = ?`,
      [
        hashedPassword,
        newKeySalt || null,
        newVerificationBlob || null,
        newRecoveryBlob || null,
        user.id,
      ]
    )

    // Re-encrypt account keys if provided
    if (Array.isArray(reEncryptedKeys) && reEncryptedKeys.length > 0) {
      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, user.id]
        )
      }
    }

    // If no BIP39 recovery, delete irrecoverable account keys
    if (clearAccountKeys) {
      await connection.query(`DELETE FROM account_keys WHERE user_id = ?`, [user.id])
      await connection.query(`UPDATE users SET recovery_blob = NULL WHERE id = ?`, [user.id])
    }

    await connection.commit()
  } catch (err) {
    await connection.rollback()
    throw err
  } finally {
    connection.release()
  }

  res.status(200).json({
    success: true,
    message: 'Contraseña actualizada. Ya puedes iniciar sesión.',
  })
})

Recovery Without BIP39

Data Loss Scenario: If you use email reset WITHOUT a BIP39 recovery phrase, your old encrypted data is permanently lost. The system will:
  1. Delete all encrypted AccountKeys
  2. Clear recovery_blob
  3. Redirect you to setup a new account
This is unavoidable with E2E encryption.

Best Practices

1

Set Up Recovery Immediately

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.

API Endpoints

BIP39 Recovery

# Save recovery blob (authenticated)
POST /api/auth/recovery-blob
Content-Type: application/json

{
  "recoveryBlob": "base64_encrypted_userkey...",
  "recoverySalt": "64_char_hex_salt..."
}
# Get recovery info (authenticated)
GET /api/auth/recovery-info

# Response:
{
  "success": true,
  "recovery_blob": "...",
  "recovery_salt": "..."
}
# Recover with BIP39 (authenticated)
POST /api/auth/recover-bip39
Content-Type: application/json

{
  "newKeySalt": "...",
  "verificationBlob": "...",
  "recoveryBlob": "...",
  "reEncryptedKeys": [
    { "accountId": "acc-001", "encryptedKey": "..." }
  ]
}

Email-Based Reset

# Request password reset (public)
POST /api/auth/forgot-password
Content-Type: application/json

{
  "email": "[email protected]"
}
# Verify reset token (public)
GET /api/auth/reset-info?token=...&email=...

# Response:
{
  "success": true,
  "key_salt": "...",
  "recovery_blob": "...",
  "recovery_salt": "...",
  "encrypted_keys": [...]
}
# Reset password (public)
POST /api/auth/reset-password
Content-Type: application/json

{
  "email": "[email protected]",
  "token": "...",
  "newPassword": "...",
  "newKeySalt": "...",
  "newVerificationBlob": "...",
  "reEncryptedKeys": [...],
  "clearAccountKeys": false
}
See backend/routes/auth/auth-routes.ts:76-82 for the full routing implementation.

Build docs developers (and LLMs) love