Как проверить пароль на утечку, не отправляя его (HIBP и k-анонимность)

#security#passwords#privacy

Когда junior в моей команде предложил добавить проверку паролей на утечки через Have I Been Pwned (HIBP), его первый инстинкт был логичен: «Просто отправим пароль на сервер и получим ответ». К счастью, PR не успел уйти в ревью — отправлять plaintext-пароли третьим сторонам ради «безопасности» это тот самый ироничный кейс, который потом разбирают на постмортемах.

Но сама идея была правильной. Просто реализовать её безопасно оказалось возможным благодаря k-анонимности — методу, который HIBP использует с 2018 года.

Почему нельзя просто хешировать?

Наивная реализация выглядит так:

  1. Хешируем пароль (SHA-1)
  2. Отправляем хеш на сервер
  3. Получаем ответ: был ли такой хеш в утечках

Проблема в том, что SHA-1 без соли легко обратима. Если злоумышленник перехватит запрос с хешем 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8, он за микросекунды узнает, что это хеш слова password. Мы фактически слили пароль.

Как работает k-анонимность в HIBP

Вместо полного хеша отправляются только первые 5 hex-символов. Сервер возвращает все известные хеши с таким префиксом (обычно 800–2000 вариантов), а клиент уже локально проверяет, есть ли среди них полный хеш пароля.

Пример для пароля password:

  1. SHA-1 хеш: 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8
  2. Отправляем только 5BAA6
  3. Получаем список суффиксов:
    1E4C9B93F3F0682250B6CF8331B7EE68FD8:52372427
    ... ещё ~2000 строк
    
  4. Клиент находит совпадение и видит, что пароль встречался в 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));
}

Подводные камни

  1. Утечка через размер ответа. Популярные префиксы (например, у 123456) возвращают больше результатов. HIBP решает это через заголовок Add-Padding: true, который добавляет случайное количество фейковых записей.

  2. Соль не поможет. Даже если добавить соль перед хешированием, это не решит проблему — серверу нужно сравнивать именно «голые» хеши из утечек.

  3. Статистические атаки. Если проверять много паролей подряд, можно вычислить паттерны. Поэтому лучше делать проверку только на клиенте и только при регистрации/смене пароля.

Когда это действительно полезно

Но не стоит использовать это как единственную метрику «качества» пароля. Хороший пароль — не просто «не утекший», а ещё и достаточно сложный.

Философский момент

Самое интересное здесь даже не криптография, а дизайн API. HIBP удалось создать систему, где:

Это редкий пример security-фичи, которая действительно respect’ит приватность. Жаль, что так мало сервисов следуют этому подходу. Сколько раз вы видели «проверку безопасности», которая на деле сливает данные в какой-нибудь сторонний трекер? У меня таких кейсов набрался целый анти-рейтинг.


Источник: https://dev.to/orthogonalinfo/check-if-a-password-was-breached-without-sending-it-hibp-k-anonymity-5j1