Миграция устаревших хешей на новый cost factor

В bcrypt.js каждый хеш включает в себя параметр cost factor (work factor), который определяет количество итераций алгоритма и напрямую влияет на вычислительную сложность вычисления хеша. Формат строки bcrypt выглядит следующим образом:

$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy

Разбор структуры:

  • $2b$ — версия алгоритма
  • 10 — cost factor (2^10 итераций)
  • далее — соль и сам хеш

Cost factor является частью хеша, что позволяет системе хранить информацию о том, с какой вычислительной “стоимостью” был создан пароль.


Проблема устаревших значений cost factor

Со временем вычислительная мощность систем растёт, и значение cost factor, которое раньше считалось безопасным, перестаёт соответствовать актуальным требованиям.

Типичная ситуация:

  • ранее использовался cost factor 8 или 9
  • текущий стандарт — 11–14 (в зависимости от инфраструктуры)
  • старые хеши остаются в базе без изменений

Это приводит к неоднородности:

  • часть пользователей защищена слабее
  • часть — сильнее
  • невозможно быстро привести систему к единому уровню безопасности без миграции

Определение cost factor из существующего хеша

bcrypt.js позволяет извлечь cost factor прямо из строки хеша:

function getCostFactor(hash) {
  return parseInt(hash.split('$')[2], 10);
}

const hash = "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy";
console.log(getCostFactor(hash)); // 10

Это ключевой механизм для принятия решения о необходимости обновления хеша.


Стратегия миграции: обновление при входе пользователя

Наиболее безопасный и распространённый подход — ленивое обновление (lazy rehashing). Суть: хеш обновляется только в момент, когда пользователь успешно аутентифицируется.

Проверка и миграция в одном потоке:

import bcrypt from "bcryptjs";

const TARGET_COST = 12;

async function verifyAndUpgrade(password, user) {
  const isValid = await bcrypt.compare(password, user.passwordHash);

  if (!isValid) {
    return false;
  }

  const currentCost = parseInt(user.passwordHash.split("$")[2], 10);

  if (currentCost < TARGET_COST) {
    const newHash = await bcrypt.hash(password, TARGET_COST);

    await updateUserPassword(user.id, newHash);
  }

  return true;
}

Преимущества подхода:

  • нет массовой нагрузки на систему
  • миграция происходит естественно
  • пользователи не ощущают изменений

Принудительная миграция при политике безопасности

Если требуется строгая унификация безопасности, используется принудительное обновление:

function needsRehash(hash, targetCost) {
  const currentCost = parseInt(hash.split("$")[2], 10);
  return currentCost !== targetCost;
}

При обнаружении устаревшего cost factor можно:

  • заставить пользователя сменить пароль
  • или автоматически пересоздать хеш при следующей аутентификации

Массовая миграция через фоновые задачи

Для крупных систем используется пакетная обработка:

async function migrateBatch(users) {
  for (const user of users) {
    const cost = parseInt(user.passwordHash.split("$")[2], 10);

    if (cost < 12) {
      const newHash = await bcrypt.hash(user.plainPasswordBackup, 12);

      await updateUserPassword(user.id, newHash);
    }
  }
}

Однако хранение plaintext паролей недопустимо, поэтому реальный сценарий требует:

  • сброса пароля
  • или миграции через повторную аутентификацию

Ограничения bcrypt.js при миграции

bcrypt.js не предоставляет автоматического механизма обновления cost factor. Все решения реализуются на уровне приложения.

Ключевые ограничения:

  • невозможно «перекодировать» хеш без исходного пароля
  • нельзя повысить cost factor без повторного вызова hash
  • каждый новый хеш требует plaintext

Сравнение стратегий миграции

Lazy rehash

  • минимальная нагрузка
  • постепенная миграция
  • зависит от активности пользователей

Forced reset

  • высокая безопасность
  • требует участия пользователя
  • может ухудшить UX

Batch migration

  • подходит для контролируемых систем
  • требует доступа к исходным паролям или reset-процедуре
  • сложна в реализации

Проверка необходимости миграции при каждом логине

Практическая реализация обычно объединяет проверку и обновление:

async function loginUser(email, password) {
  const user = await findUserByEmail(email);

  const valid = await bcrypt.compare(password, user.passwordHash);
  if (!valid) return null;

  const currentCost = parseInt(user.passwordHash.split("$")[2], 10);

  if (currentCost < 12) {
    const upgradedHash = await bcrypt.hash(password, 12);
    await updateUserPassword(user.id, upgradedHash);
  }

  return user;
}

Такой подход позволяет постепенно привести всю базу к актуальному уровню безопасности без отдельной миграции данных.


Особенности выбора нового cost factor

Увеличение cost factor должно учитывать баланс:

  • безопасность
  • нагрузку на сервер
  • время ответа API

Практически применяемые значения:

  • 10 — минимально допустимое для современных систем
  • 12 — стандарт для большинства веб-приложений
  • 14+ — для систем с высокой чувствительностью данных

Ошибки при миграции хешей

На практике часто встречаются следующие проблемы:

  • попытка пересчитать хеш без plaintext
  • отсутствие извлечения cost factor из строки
  • массовая миграция без нагрузки-лимитов
  • игнорирование обратной совместимости старых хешей

Корректная модель всегда опирается на то, что bcrypt хеш является самодостаточным источником метаданных, включая cost factor.


Совместимость версий bcrypt

Разные версии bcrypt используют одинаковый принцип хранения cost factor внутри хеша:

  • $2a$ — старый стандарт
  • $2b$ — актуальный и наиболее распространённый
  • $2y$ — специфические реализации

bcrypt.js корректно работает с этими форматами при сравнении, но при миграции важно сохранять единый формат нового хеша.