January 19

Храним пароли безопасно. Интерактивное руководство по bcrypt в Python

Краткое предисловие

Тема болезненная, обширная и важная и касается буквально каждого из нас, хотелось бы нам этого или нет.

В современном мире трудно представить человека, который не имеет, например, электронной почты и не зарегистрирован ни в каком сервисе. Как известно, при регистрации мы обычно указываем логин и пароль и на этом наша сторона ответственности заканчивается (а, нет, еще в нашей ответственности создать себе надежный пароль из примерно 16 символов, который содержит и строчные и заглавные буквы, и спецсимволы и желательно вообще нечитабельный, а еще - уникальный для каждого сервиса :)). Способ хранения паролей же лежит на стороне разработчика сервиса и обычно мы об этом никогда не узнаем, так как исходные коды обычно закрыты.

Чем же плохо хранение паролей в открытом виде?


Это однозначно небезопасно и может привести к серьезным последствиям.

Как минимум, если злоумышленник вдруг получит доступ к базе данных сервиса, он получит сразу и пары логин-пароль всех пользователей и сможет делать многие действия от имени взломанных бедолаг.

Кроме того, как мы обычно поступаем? Мы придумываем 1 пароль для многих сервисов для удобства. А если злоумышленник уже получил логин-пароль от одного сервиса, то можно считать, что он уже получил ключики от всех дверей. Осталось их только перебрать :).

Погружаемся в тему


Итак, давайте в рамках статьи представим, что у нас есть вымышленный сервис доставки dostavallo, который хранит пароли в открытом виде, примерно так:

------------------------------------------
| email | password |
| ------------------ |-------------------- |
| user1@gmail.com | my_best_password |
| user2@yandex.ru | secured_password |
------------------------------------------
Поможем ему обезопаситься и не потерять пользователей!

База

Без теории никак, поэтому немного ознакомимся с основными терминами.

Хэш-функция

Криптографическая хэш-функция, упрощенно говоря - это математическая функция, превращающая последовательность входных данных в строку фиксированной длины, состоящей из букв и цифр.

Главная особенность полученных хэшей в том, что они односторонние, то есть расшифровать их и получить исходный пароль уже не получится.

Соль

Часто в контексте безопасного хранения паролей можно услышать фразу "Надо посолить пароль" :) Что же это значит?

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

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

Радужные таблицы

Радужные таблицы - это специальный вариант таблиц поиска для обращения криптографических хеш-функций, использующий механизм разумного компромисса между временем поиска по таблице и занимаемой памятью.

Проще говоря, это таблицы, которые позволяют быстро найти соответствие между хэшем и исходным паролем. Если пароли защищены солью, радужные таблицы становятся бесполезными.

Почему не шифрование?

К слову, почему хэширование, а не шифрование? Потому что это односторонний процесс и восстановить исходный пароль уже вряд ли получится.
Шифрование подходит для данных, которые нужно вернуть в исходный вид, например, для передачи сообщений. В случае с паролями такой необходимости нет, поэтому хэширование предпочтительнее.

За дело берется bcrypt

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

В Python уже есть готовая библиотека, которую мы и используем.

Реализация

Давайте напишем небольшой скрипт, который будет хэшировать пароли.

Для начала устанавливаем библиотеку к себе в проект:

pip install bcrypt

Попробуем посолить и хэшировать пароль:

# Наш исходный пароль: 123
password = "123"
 
# Генерируем соль, хэшируем пароль с солью и предоставляем его
# в виде строки из байткода с помощью функции decode()
hashed_password = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
 
# Теперь наш пароль выглядит примерно так:
print(hashed_password)
>>> '$2b$12$G3gSjjkBe1Bq3zwOGbmIXOUQ9C48kYcB6lJndABNOiGxJdip1.EES'

Отлично! Теперь мы представили наш пароль в виде, который нельзя расшифровать, но как же теперь проверить, что наш исходный пароль соответствует хэшированному?

Все довольно просто, надо лишь использовать функцию checkpw, которая возвращает результат типа bool:

password = "123"
hashed_password = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
 
# Проверяем, что указанный нами пароль соответствует хэшу
print(bcrypt.checkpw(password.encode(), hashed_password.encode()))
>>> True
 
# Попробуем указать неправильный пароль и получим False
wrong_password = "1234"
print(bcrypt.checkpw(wrong_password.encode(), hashed_password.encode()))
>>> False

Теперь мы убедились, что можно хранить пароль в хэшированном виде, который абсолютно не читаем и устойчив к атакам. Даже если он утечет, с ним мало что можно сделать. При этом, всегда можно проверить, корректен ли введенный пароль хэшированному, чтобы принять решение, пускать ли пользователя в систему.

Набираем обороты и помогаем сервису dostavallo с безопасностью

Давайте теперь рассмотрим пример посложнее, реализуем небольшое приложение командной строки, которое будет реализовывать функционал регистрации и входа в систему, а пары логин-пароль мы будем просто хранить в json-файлике.

Обратите внимание, что в реальных приложениях пароли и другие данные пользователей хранятся в защищенных базах данных, а не в файлах. :)

Пример будет рассмотрен одним блоком кода с комментариями / документацией. Погнали!

Реализация программы

import bcrypt  
import json
 

# Путь до файла, в котором будут храниться данные о пользователях  
USERS_FILEPATH = "users.json"

  
def hash_password(password: str) -> str:  
    """Функция для хэширования пароля"""  
    return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()  
  
  
def is_password_correct(password: str, hashed_password: str) -> bool:  
    """Функция для проверки пароля. Если ок - возвращает True"""  
    return bcrypt.checkpw(password.encode(), hashed_password.encode())  
  
  
def load_users(file_path=USERS_FILEPATH) -> dict:  
    """Функция для загрузки пользователей из файла"""  
    try:  
        with open(file_path, "r") as file:  
            return json.load(file)  
    except FileNotFoundError:  
        return {}  
  
  
def save_user(user: dict, file_path=USERS_FILEPATH) -> None:  
    """Функция для сохранения регистрационных данных пользователя (login-password) в файл"""  
    with open(file_path, "w") as file:  
        json.dump(user, file, indent=4)  
  
  
def register_user(users: dict) -> dict:  
    """
    Функция регистрации пользователя.
    Внутри происходит хэширование пароля и сохранение данных пользователя в файл.
    """
    username = input("Введите имя пользователя: ").strip()  
    if username in users:  
        print(f"Пользователь {username} уже существует.")  
        return users  
  
    password = input("Введите пароль: ").strip()  
    hashed_password = hash_password(password)  
    users[username] = hashed_password  
    save_user(users)  
    print(f"Пользователь {username} успешно зарегистрирован!")  
    return users  
  
  
def login_user(users: dict) -> None:  
    """  
    Функция для логина пользователя в систему.  
    При удачном входе, выполнение программы завершается.
    """
    username = input("Введите имя пользователя: ").strip()  
    if username not in users:  
        print(f"Пользователь {username} не найден.")  
        return  
  
    while True:  
        password = input("Введите пароль (или нажмите 'q' для возврата в главное меню): ").strip()  
        if password.lower() == 'q':  
            print("Возврат в главное меню.")  
            return  
  
        if is_password_correct(password=password, hashed_password=users[username]):  
            print(f"\nВведен Правильный пароль, вход выполнен. \nДобро пожаловать, {username}!")  
            exit()  
        else:  
            print("Неверный пароль! Попробуйте еще раз.")  
  
  
# Основной цикл программы  
def main():  
    users = load_users()  
    print("Добро пожаловать в сервис доставки 'dostavallo'!")  
    while True:  
        print("\nВыберите действие:")  
        print("1. Зарегистрироваться")  
        print("2. Войти в систему")  
        print("3. Выход")  
  
        choice = input("Ваш выбор: ").strip()  
  
        match choice:  
            case "1":  
                users = register_user(users)  
            case "2":  
                login_user(users)  
            case "3":  
                print("Выход из программы. До свидания!")  
                break  
            case _:  
                print("Неверный выбор. Попробуйте еще раз.")  
  
  
if __name__ == "__main__":  
    main()

Запускаем

Давайте залогиним моего кота в систему, чтобы он мог заказать себе вкусных рыбов.

Запустим программу:

python3 bcrypt_test.py

В зависимости от системы, здесь может быть просто python bcrypt_test.py. Также программу можно запустить через IDE.

После запуска программа дружелюбно приветствует нас:

Добро пожаловать в сервис доставки 'dostavallo'!

Выберите действие:
1. Зарегистрироваться
2. Войти в систему
3. Выход
Ваш выбор: 

Выберем регистрацию, для этого достаточно ввести цифру 1.
Вводим имя пользователя и пароль:

Ваш выбор: 1    
Введите имя пользователя: Bublik
Введите пароль: Fi$h

Видим сообщение об успешной регистрации и переход в главное меню:

Пользователь Bublik успешно зарегистрирован!

Выберите действие:
1. Зарегистрироваться
2. Войти в систему
3. Выход
Ваш выбор: 

Выберем вход, для этого введем цифру 2.
Далее вводим логин и пароль, которые мы только что создали:

Ваш выбор: 2
Введите имя пользователя: Bublik
Введите пароль (или нажмите 'q' для возврата в главное меню): Fi$h

Введен Правильный пароль, вход выполнен. 
Добро пожаловать, Bublik!

Удача буквально преследует нас, все получилось!

А что с паролями для dostavallo?

Давайте откроем файлик users.json и посмотрим, как хранятся данные наших пользователей. Внутри мы увидим примерно такое:

{  
    "Bublik": "$2b$12$VgDqlE5Xcwm7ID20Aw9isuA.qQp1o.hnMN1./8xxrqS1qXdRpuCW2"  
}

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

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

Послесловие

Теперь мы знаем, что соль бывает не только поваренной, а радужные таблицы - это вовсе не про закрашенные ячейки в MS Excel :)

Познакомились поближе с хэш-функциями и спасли сервис доставки от беды, а кота - от шантажа.

Кстати, если хотите проверить, не утекал ли ваш пароль куда-нибудь, можете сделать это с помощью сайта https://haveibeenpwned.com. Просто вводите туда свою почту и получите ответ :).

Если увидели там что-то, поступаем просто - меняем пароль для указанного сервиса.

Я вот себя нашел. По информации сайта, мои данные подвергались утечке 3 раза.

Подробнее:

  • https://www.securitylab.ru/blog/personal/xiaomite-journal/353801.php
  • https://en.wikipedia.org/wiki/Have_I_Been_Pwned%3F

Берегите себя во всех смыслах.


Мой telegram-канал, присоединяйтесь :)
⬇⬇⬇
https://t.me/python3_with_love