Когда junior в моей команде предложил добавить проверку паролей на утечки через Have I Been Pwned (HIBP), его первый инстинкт был логичен: «Просто отправим пароль на сервер и получим ответ». К счастью, PR не успел уйти в ревью — отправлять plaintext-пароли третьим сторонам ради «безопасности» это тот самый ироничный кейс, который потом разбирают на постмортемах.
Но сама идея была правильной. Просто реализовать её безопасно оказалось возможным благодаря k-анонимности — методу, который HIBP использует с 2018 года.
Почему нельзя просто хешировать?
Наивная реализация выглядит так:
- Хешируем пароль (SHA-1)
- Отправляем хеш на сервер
- Получаем ответ: был ли такой хеш в утечках
Проблема в том, что SHA-1 без соли легко обратима. Если злоумышленник перехватит запрос с хешем 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8, он за микросекунды узнает, что это хеш слова password. Мы фактически слили пароль.
Как работает k-анонимность в HIBP
Вместо полного хеша отправляются только первые 5 hex-символов. Сервер возвращает все известные хеши с таким префиксом (обычно 800–2000 вариантов), а клиент уже локально проверяет, есть ли среди них полный хеш пароля.
Пример для пароля password:
- SHA-1 хеш:
5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8 - Отправляем только
5BAA6 - Получаем список суффиксов:
1E4C9B93F3F0682250B6CF8331B7EE68FD8:52372427 ... ещё ~2000 строк - Клиент находит совпадение и видит, что пароль встречался в 52 млн утечек
Сервер не знает, какой именно хеш мы проверяли — он отдал все возможные варианты с этим префиксом. Это и есть k-анонимность: наш запрос «растворился» среди сотен других.
Реализация на практике
Вот как это выглядит в Python:
import hashlib
import urllib.request
def check_password(password):
sha1 = hashlib.sha1(password.encode()).hexdigest().upper()
prefix, suffix = sha1[:5], sha1[5:]
with urllib.request.urlopen(f"https://api.pwnedpasswords.com/range/{prefix}") as res:
for line in res.read().decode().splitlines():
if line.startswith(suffix):
return int(line.split(':')[1])
return 0
В браузере можно использовать Web Crypto API:
async function isPwned(password) {
const hashBuffer = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(password));
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase();
const response = await fetch(`https://api.pwnedpasswords.com/range/${hashHex.slice(0, 5)}`);
const suffixes = await response.text();
return suffixes.includes(hashHex.slice(5));
}
Подводные камни
-
Утечка через размер ответа. Популярные префиксы (например, у
123456) возвращают больше результатов. HIBP решает это через заголовокAdd-Padding: true, который добавляет случайное количество фейковых записей. -
Соль не поможет. Даже если добавить соль перед хешированием, это не решит проблему — серверу нужно сравнивать именно «голые» хеши из утечек.
-
Статистические атаки. Если проверять много паролей подряд, можно вычислить паттерны. Поэтому лучше делать проверку только на клиенте и только при регистрации/смене пароля.
Когда это действительно полезно
- При регистрации новых пользователей
- В формах смены пароля
- В пайплайнах CI для проверки паролей в тестовых данных
Но не стоит использовать это как единственную метрику «качества» пароля. Хороший пароль — не просто «не утекший», а ещё и достаточно сложный.
Философский момент
Самое интересное здесь даже не криптография, а дизайн API. HIBP удалось создать систему, где:
- Сервер ничего не знает о вашем запросе
- Клиент получает ровно столько данных, сколько нужно
- Нет API-ключей и rate limits
- CORS настроен корректно
Это редкий пример security-фичи, которая действительно respect’ит приватность. Жаль, что так мало сервисов следуют этому подходу. Сколько раз вы видели «проверку безопасности», которая на деле сливает данные в какой-нибудь сторонний трекер? У меня таких кейсов набрался целый анти-рейтинг.