Пароли: почему хеш, соль и стоимость

доступ≈ 15 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Базы утекают: через дыру в запросе, забытую резервную копию, потерянный ноутбук. В части IV хеш пароля едва не уехал в браузер вместе с профилем («Обратно тоже едет через сеть») — там это была ошибка границы. Здесь вопрос другой: что должно лежать в колонке passwordHash, чтобы её утечка не стала утечкой паролей. Три слова заголовка — хеш, соль и стоимость — закрывают три разные атаки, и каждую мы проведём сами. Песочница учебника умеет настоящий SHA-256 и настоящий PBKDF2 через crypto.subtle — тот же алгоритм, что стоит на серверах, а не игрушку. А под конец настоящий Node посчитает scrypt и argon2id и покажет, что за них платят памятью.

Разделы темы:·

Начнём с очевидного, чтобы к нему не возвращаться. Пароль открытым текстом — это пароли всех пользователей в руках каждого, кто увидел таблицу: злоумышленника, администратора, сервиса резервных копий. А люди повторяют пароли, так что утекает не только ваш сайт. Зашифровать? Сервер сверяет пароль при каждом входе, значит, ключ расшифровки должен быть под рукой у приложения, — и утечка, которая дотянулась до базы через приложение, дотягивается и до его секретов. Зашифрованное можно расшифровать, в этом вся беда.

Сверять пароль можно, и не зная его. Хеш — функция в одну сторону: из пароля строку получить легко, а из строки пароль — только угадыванием. При регистрации сервер кладёт в таблицу хеш, при входе считает хеш присланного пароля и сравнивает. SHA-256 вы уже видели: в разделе «hash: телефон не верит файлу на слово» им проверяли скачанный файл. Возьмём его.

Четыре человека регистрируются, база утекает. У злоумышленника словарь — шесть самых частых паролей; настоящие словари собраны из прошлых утечек и тянут на миллионы строк, но устроены так же. Прогноз: сколько паролей он узнает и что увидит в таблице ещё до того, как откроет словарь?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три пароля из четырёх — и ничего не взломано в привычном смысле: хеш никто не обращал. Злоумышленник посчитал хеши шести строк и поискал их в таблице. Причём таблицу «хеш → пароль» можно посчитать один раз и заранее, до всякой утечки, и она подойдёт к любой базе, где лежит голый SHA-256; готовые таблицы для таких хешей давно выложены в сеть. А первая строка вывода не потребовала даже словаря: одинаковый хеш — это одинаковый пароль. Узнав пароль anya, злоумышленник узнал и пароль vera.

SHA-256 ничего не шифрует. Он быстро превращает одну строку в другую, и злоумышленник делает это ровно так же быстро, как ваш сервер. Защита начинается там, где у него это перестаёт получаться.

Обе беды прошлого раздела растут из одного корня: хеш зависит только от пароля. Значит, к паролю надо подмешать что-то своё у каждой строки. Это соль — 16 случайных байт, которые сервер создаёт при регистрации и кладёт в таблицу рядом с хешем. Секретом соль не является и не должна им быть: при входе сервер берёт её из той же строки, где лежит хеш.

Те же четыре человека, та же утечка, но теперь с солью. У злоумышленника его таблица из прошлого примера и тот же словарь из шести строк. Прогноз: сколько найдёт таблица, сколько найдёт перебор и сколько SHA-256 придётся посчитать?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Таблица не нашла ничего, и одинаковых хешей больше нет: пароль у anya и vera общий, а соль разная. Но перебор нашёл те же три пароля. Соль не делает слабый пароль сильным — она отменяет оптовую скидку. Раньше шесть вычислений вскрывали всю базу, а заодно и любую другую; теперь словарь считается заново для каждой строки: 24 вычисления на четыре строки, а в жизни — миллионы строк словаря на каждого из тысяч пользователей.

Не изменилось одно: попытка по-прежнему стоит один SHA-256. Этот хеш придуман быстрым — для проверки файла это достоинство. На машине автора один SHA-256 короткой строки в Node занимает около микросекунды: больше полумиллиона попыток в секунду на одном ядре, и это ещё с накладными расходами JavaScript на каждый вызов.

Адрес уникален, но предсказуем. Таблицу для admin@ нужного сайта можно посчитать до утечки, та же почта даёт ту же соль на всех сайтах с такой схемой, а при смене пароля соль не меняется. Случайные байты из crypto.getRandomValues ничего не стоят, и экономить на них незачем.

Сделать хеш медленным для злоумышленника можно только одним способом — сделать его медленным для всех. PBKDF2 прогоняет пароль с солью через HMAC-SHA256 заданное число раз, и каждый шаг берёт результат предыдущего: одну цепочку не распараллелить, шаги не пропустить. HMAC здесь тот же, которым подписывают токен входа, — его разберём в статье «Собираем токен: три части, подпись, срок, подделка». Число повторов называется стоимостью. В браузере PBKDF2 встроен в crypto.subtle: пароль импортируется как ключ, а deriveBits выдаёт нужное число бит.

OWASP сегодня советует для PBKDF2-HMAC-SHA256 не меньше 600 000 итераций. Посчитаем 1 000, 100 000 и 600 000 на вашей машине. Миллисекунды у каждого читателя свои, поэтому пример печатает соотношения. Прогноз: во сколько раз дольше 100 000 итераций, чем 1 000, и 600 000, чем 100 000?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Время растёт почти ровно по числу итераций. На машине автора 600 000 дольше 100 000 в шесть раз, а 100 000 дольше 1 000 — примерно в 80 раз, а не в 100: у дешёвого вызова заметная доля времени уходит на импорт ключа и сам вызов, а не на итерации. Каждая итерация — это HMAC, то есть два прохода SHA-256, так что 600 000 итераций — больше миллиона SHA-256 на одну попытку. Злоумышленник платит эту цену за каждую строку словаря, вы — за каждый вход: от десятков миллисекунд до сотни, раз в сессию.

Что именно это покупает? Переберём словарь по одной строке утёкшей базы — сначала при 1 000 итераций, потом при 100 000. У gleb пароль из словаря, у boris — нет. Прогноз: на какой попытке найдётся gleb при каждой стоимости и во сколько раз подорожает весь проход по словарю?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

gleb найден на четвёртой попытке при любой стоимости: пароль из верхушки словаря стоимость не спасает, его пробуют первым. boris не найден ни там, ни там, но проход, который это выяснил, подорожал в десятки раз, — ровно это стоимость и покупает. Настоящий словарь — миллионы строк. При сотне миллисекунд на попытку проход по миллиону строк против одного пользователя — больше суток работы одного ядра, против тысячи пользователей — годы. Ядер у злоумышленника больше одного, поэтому стоимость поднимают до предела, при котором вход ещё не ощущается. А от «qwerty123» не спасает ничто, кроме запрета на такие пароли при регистрации.

Стоимость — ручка, а не константа. Железо дешевеет, и число, честное сегодня, через несколько лет станет скидкой для перебора. Поэтому стоимость хранят рядом с хешем — об этом последний раздел.

У PBKDF2 есть слабость, которой не видно на одном ядре: ему почти не нужна память. Вся цепочка — несколько сотен байт состояния, и видеокарта с тысячами простых ядер ведёт тысячи таких цепочек сразу. Поэтому появились алгоритмы, которым на каждую попытку нужны мегабайты: scrypt и argon2id — вариант Argon2, победителя Password Hashing Competition 2015 года. Каждая одновременная попытка занимает свою память, и число попыток упирается уже не только в ядра, но и в объём и скорость памяти.

В Node все три алгоритма встроены в node:crypto, argon2 — начиная с 24.7.0. Прогон хеширует один пароль тремя алгоритмами с параметрами из OWASP, проверяет верный и неверный пароль, сравнивает проверку с одним SHA-256 и смотрит, на сколько вырос пик памяти процесса. Первым делом он пробует посчитать scrypt с рекомендованными параметрами как есть. Прогноз: чем кончится эта попытка и какой из трёх алгоритмов прибавит больше всего памяти?

прогон node 26.5.0 · 15 сентября 2026
Соль и хеш в выводе закрыты маской: они новые при каждом запуске. Смотрите на первые строки и на память
import { argon2, createHash, pbkdf2, randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';

// параметры — минимум из OWASP Password Storage Cheat Sheet;
// пик памяти процесса только растёт, поэтому замер памяти зависит от порядка в этом списке
const algorithms = {
  'pbkdf2-sha256': {
    params: 'i=600000',
    derive: (password, salt, p) => promisify(pbkdf2)(password, salt, p.i, 32, 'sha256'),
  },
  argon2id: {
    params: 'v=19$m=19456,t=2,p=1',
    derive: (password, salt, p) =>
      promisify(argon2)('argon2id', { message: password, nonce: salt, memory: p.m, passes: p.t, parallelism: p.p, tagLength: 32 }),
  },
  scrypt: {
    params: 'ln=17,r=8,p=1',
    derive: (password, salt, p) =>
      promisify(scrypt)(password, salt, 32, { N: 2 ** p.ln, r: p.r, p: p.p, maxmem: 256 * 1024 * 1024 }),
  },
};

// "v=19$m=19456,t=2,p=1" → { v: 19, m: 19456, t: 2, p: 1 }
const readParams = (text) => Object.fromEntries(text.split(/[$,]/).map((pair) => pair.split('=')).map(([k, v]) => [k, Number(v)]));
const b64 = (bytes) => bytes.toString('base64').replace(/=+$/, '');

async function hashPassword(password, name) {
  const { params, derive } = algorithms[name];
  const salt = randomBytes(16);
  return `$${name}$${params}$${b64(salt)}$${b64(await derive(password, salt, readParams(params)))}`;
}

async function verifyPassword(password, stored) {
  const [, name, ...rest] = stored.split('$');
  const [salt, hash] = rest.splice(-2).map((part) => Buffer.from(part, 'base64'));
  const derived = await algorithms[name].derive(password, salt, readParams(rest.join('$')));
  return timingSafeEqual(derived, hash);
}

const yes = (flag) => (flag ? 'да' : 'нет');
const peakMiB = () => process.resourceUsage().maxRSS / 1024;

// сколько стоит одна попытка быстрым хешем
const start = performance.now();
for (let i = 0; i < 10_000; i += 1) createHash('sha256').update('qwerty123').digest();
const sha256 = (performance.now() - start) / 10_000;

try {
  await promisify(scrypt)('qwerty123', randomBytes(16), 32, { N: 2 ** 17, r: 8, p: 1 });
} catch (err) {
  console.log('scrypt с maxmem по умолчанию:', err.code);
  console.log(' ', err.message);
}

for (const name of Object.keys(algorithms)) {
  const before = peakMiB();
  const stored = await hashPassword('qwerty123', name);
  const memory = peakMiB() - before;
  const began = performance.now();
  const right = await verifyPassword('qwerty123', stored);
  const slower = (performance.now() - began) / sha256;
  const wrong = await verifyPassword('qwerty', stored);
  console.log(stored);
  console.log(`  верный: ${right} | неверный: ${wrong} | дольше SHA-256 больше чем в 1000 раз: ${yes(slower > 1000)}`);
  console.log(`  памяти прибавилось больше 16 МиБ: ${yes(memory > 16)} | больше 100 МиБ: ${yes(memory > 100)}`);
}
node cost.mjs

scrypt с рекомендованными N: 2 ** 17 и r: 8 упал, не начав считать. Node ограничивает его память опцией maxmem — по умолчанию 32 МиБ, а этим параметрам нужно около 128 * N * r байт, 128 МиБ. Память здесь и есть стоимость, и её надо разрешить явно. Пик памяти процесса подтверждает это: PBKDF2 не прибавил и 16 МиБ, argon2id прибавил больше 16 (его m=19456 — килобайты, 19 МиБ), scrypt — больше 100.

Остальное у трёх строк общее, и это важнее выбора алгоритма. Проверка каждого в тысячи раз дороже одного SHA-256. Строка записана в формате PHC — $алгоритм$параметры$соль$хеш, и verifyPassword берёт из неё всё нужное, не зная, с какими параметрами хеш создавали. Асинхронные pbkdf2, scrypt и argon2 считаются в пуле потоков libuv, и цикл событий в это время свободен, а варианты *Sync держат его всё время счёта. Пул не резиновый — по умолчанию четыре потока, и пятый одновременный вход ждёт очереди; как это выглядит на настоящих числах — в статье «Настоящий цикл, libuv и пул потоков».

алгоритмминимум OWASPв Node
argon2idm=19456 (19 МиБ), t=2, p=1crypto.argon2, с 24.7.0
scryptN=217 (128 МиБ), r=8, p=1crypto.scrypt с явным maxmem
bcryptстоимость от 10, пароль не длиннее 72 байтнет, пакеты bcrypt или bcryptjs
PBKDF2-HMAC-SHA256600 000 итерацийcrypto.pbkdf2, в браузере crypto.subtle

Порядок в таблице — порядок OWASP: argon2id, если его нет — scrypt; bcrypt — для систем, где он уже стоит; PBKDF2 — там, где требуется сертификация FIPS-140. Числа сверены с OWASP Password Storage Cheat Sheet 15 сентября 2026 года.

bcrypt старше обоих, 1999 года, и живёт в огромном числе проектов, в том числе в API самого учебника: там чистый JS-пакет bcryptjs 3 со стоимостью 10. Стоимость у bcrypt — степень двойки: 10 значит 210 раундов, и каждая единица сверху удваивает время. На машине автора проверка при 10 занимает около 80 мс, при 12 — около 330.

Ловушка bcrypt — длина. Он читает только первые 72 байта пароля, а остальное молча отбрасывает. В UTF-8 русская буква занимает два байта, поэтому пароль из 36 русских букв и тот же пароль с любым хвостом для bcrypt одинаковы: bcryptjs 3.0.3 на такой паре отвечает true. Новому проекту на Node 24.7 и новее проще взять встроенный argon2id; что ещё брать для паролей и токенов, а что уже встроено — на «полке сервера».

Своя схема вроде «SHA-256 от соли и пароля, и ещё раз SHA-256» почти всегда оказывается быстрым хешем в новой обёртке. Все четыре алгоритма из таблицы лежат в стандартной библиотеке или в одном пакете, изобретать тут нечего.

Прогон уже показал формат: алгоритм, параметры, соль и хеш в одной строке. Это не украшение. Стоимость придётся поднимать, а пересчитать старые хеши сервер не может — паролей у него нет. Значит, в базе годами живут строки разной стоимости, и проверять каждую надо её собственными параметрами.

Пересчитать хеш можно в единственный момент — при успешном входе: только тогда пароль у сервера в руках и уже известно, что он верный. Проверили строку её параметрами, увидели, что стоимость ниже нынешней, записали новый хеш. Сравнивают при этом без раннего выхода: === и every останавливаются на первом расхождении, и по времени ответа можно судить, сколько совпало до него. Для солёного хеша это скорее страховка, чем дыра — соли злоумышленник не знает и подобрать пароль под нужное начало хеша не может, — но страховка стоит одну функцию: в Node это timingSafeEqual, в песочнице — цикл с |=.

anya зарегистрировалась давно, при 1 000 итераций; сегодня стоимость — 100 000 (меньше рекомендованной, чтобы пример шёл быстро). Она ошибается паролем, потом входит дважды. Прогноз: после какого из трёх входов строка в базе сменит стоимость?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Неудачный вход строку не тронул и не должен: пароль был чужой, и записать его хеш значило бы сменить пароль на ошибочный. Первый удачный вход переписал строку на 100 000, второй уже ничего не менял. Тем, кто не заходит годами, старая стоимость так и останется; OWASP для таких предлагает со временем стирать хеши и просить задать пароль заново через восстановление.

У нового хеша есть побочный эффект, на котором стоит целая техника. Соль при каждом хешировании новая, поэтому строка меняется даже при том же пароле. Если подписать ссылку восстановления секретом, выведенным из текущего хеша, первая же смена пароля погасит все выданные ссылки; перехеширование при входе погасит их тоже. Как это устроено и чем за это платят — в статье «Восстановление пароля и письмо, которое обязано дойти».

  • Пароль не хранят ни открытым текстом, ни зашифрованным: ключ расшифровки живёт рядом с приложением и утекает вместе с ним. В базе лежит хеш — из него пароль можно только угадать.
  • Голый быстрый хеш угадывают оптом: таблицу «хеш → пароль» считают заранее, а одинаковые пароли видны по одинаковым строкам. Соль — случайные байты, свои у каждой строки и открыто лежащие рядом с хешем, — отменяет и то и другое, но слабый пароль сильным не делает.
  • Стоимость умножает цену каждой попытки: 100 000 итераций PBKDF2 дольше 1 000 в десятки раз, и перебор базы дорожает так же. Пароль из верхушки словаря находят на той же попытке — от него защищает запрет при регистрации, а не стоимость.
  • PBKDF2 почти не нужна память, и видеокарта ведёт тысячи его цепочек разом. scrypt и argon2id требуют мегабайты на попытку; OWASP советует argon2id с m=19456, t=2, p=1, а scrypt с N=217 в Node без явного maxmem падает.
  • Хеш хранят строкой $алгоритм$параметры$соль$хеш и проверяют её же параметрами, сравнивая без раннего выхода (timingSafeEqual). На нынешнюю стоимость строку переписывают при успешном входе — только тогда у сервера есть пароль.
  • Асинхронные pbkdf2, scrypt и argon2 считаются в пуле потоков libuv и не держат цикл событий; варианты *Sync держат его всё время счёта.

1/4Почему пароли в базе хешируют, а не шифруют?

2/4Соль лежит в той же строке, что и хеш, и утекает вместе с ним. Что она тогда даёт?

3/4Стоимость PBKDF2 подняли с 1 000 до 100 000 итераций. Что стало с паролем 1q2w3e4r при переборе утёкшей базы?

4/4В базе лежат хеши со стоимостью 1 000, а политика теперь — 600 000. Когда сервер может пересчитать хеш пользователя?

Подняли стоимость, и старые пользователи не входят

В API подняли стоимость с 1 000 до 100 000 итераций и выкатили. Через час поддержка тонет в письмах «пароль верный, а войти не могу», при этом новые пользователи входят без проблем. Найдите, почему verifyPassword отказывает старым, и почините так, чтобы входили все, а чужой пароль по-прежнему не подходил.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Проверка берёт стоимость из константы, а не из строки. Хеш anya посчитан при 1 000 итераций, а сверяется при 100 000 — байты не совпадут никогда, какой пароль ни введи. Стоимость надо читать оттуда же, откуда соль и хеш:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод становится anya входит своим паролем: true, boris входит своим паролем: true, чужой пароль к boris: false.

Коварство ошибки в том, что до выкатки её не видно: тесты заводят пользователей с нынешней стоимостью, и все проходят. Ломаются только настоящие старые строки — ради которых стоимость и хранят рядом с хешем. На новую стоимость их переведёт следующий удачный вход, как в разделе про строку, которая помнит свою стоимость.

Вход подсказывает, есть ли такой адрес

Вход отвечает одним текстом и на чужой адрес, и на неверный пароль — «неверный адрес или пароль». Но замер показывает, что по ответу всё равно видно, зарегистрирован ли адрес. Найдите, какой из ответов выдаёт себя и почему, и сделайте так, чтобы время ответа не зависело от того, есть ли адрес в базе. hashPassword и verifyPassword — из раздела про строку со стоимостью, они в подложке.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Для неизвестного адреса login возвращается сразу, а для известного сначала считает PBKDF2 в 100 000 итераций. Текст одинаковый, время — нет, и перебором адресов можно собрать список зарегистрированных, не угадав ни одного пароля. Стоимость, которая защищает хеши, здесь сама стала подсказкой.

Лечится тем, что проверка идёт всегда, а при неизвестном адресе — против заранее посчитанного хеша, к которому не подойдёт ни один пароль:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод: ответы одинаковые: true и один из ответов быстрее больше чем в 3 раза: нет.

Проверка !stored остаётся обязательной: «пароль подошёл к пустышке» не должно становиться входом. Пустышку хешируют той же стоимостью, что и настоящие строки, иначе разница вернётся. И честная оговорка: если регистрация отвечает «адрес занят», список адресов соберут через неё, так что закрывать приходится обе двери — например, ограничением частоты попыток.

Соль одна на всё приложение

Здесь соль есть — строка с именем сайта, одна для всех. Посмотрите, что видно в утёкшей таблице и сколько паролей находит одна таблица злоумышленника, и почините регистрацию.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Одна соль на всех — это не соль, а просто другой хеш: одинаковые пароли снова дают одинаковые строки (разных хешей на три строки: 2), и таблицу злоумышленнику достаточно посчитать один раз — под этот сайт. Соль должна быть своя у каждой строки:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь разных хешей на три строки: 3 и найдено по одной таблице: 0: таблица, посчитанная на соль этого сайта, не подходит больше ни к одной строке. В register константа SALT не нужна вовсе — в примере она остаётся только у злоумышленника, который считал ею свою таблицу. Хеш в этой задаче по-прежнему быстрый SHA-256: в настоящей базе соль нужна вместе со стоимостью. А общая для всех строк секретная добавка, которая хранится не в базе, у OWASP называется иначе — перец, и она дополняет соль, а не заменяет её.