Менеджер паролей на python

в 11:31, , рубрики: бесплатно, менеджер паролей, открытый код

Предисловие

Это не полноценный гайд по созданию менеджера паролей и не рекомендация использовать мой подход в production. Скорее, это разбор моего опыта разработки локального менеджера паролей на Python и PySide6: какие решения я выбрал, с какими проблемами столкнулся и что понял по ходу работы.

Проект некоммерческий

Начало начал

В 2024 году я начал разрабатывать свой мессенджер на QT, но через примерно год я встал в тупик и решил на примере не сложного проекта разобраться в QT и попробовать нормально построить архитектуру. Основная цель проекта была не в создании идеального менеджера паролей, а в тренировке архитектуры и работы с PySide6.

Технологии

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

В качестве интерфейса был выбран QT через библиотеку pyside6. С помощью QT можно сделать красивый интерфейс и пример этом — телеграм.

Я выбрал гибридную схему: данные шифруются симметричным ключом, а сам симметричный ключ шифруется RSA. Такой подход удобнее, чем шифровать все данные напрямую RSA, потому что асимметричное шифрование плохо подходит для больших объемов данных.

Однако в моей первой реализации есть спорные места. Например, используется RSA-1024, что сейчас уже нельзя считать хорошим выбором. В дальнейшем я бы заменил это минимум на RSA-2048/3072 или пересмотрел схему в сторону более современной криптографии.

    #Метод генерации синхронного ключа
    def gen_sync_key(self):
        with open(self.sync_key_path, 'wb') as key:
            key.write(get_random_bytes(32))
    
    #Метод генерации асинхронного ключа
    def gen_async_key(self):
        keys = RSA.generate(1024)
        with open(self.async_public_key_path, 'wb') as key_pub:
            key_pub.write(keys.public_key().export_key())
        with open(self.async_private_key_path, 'wb') as key_private:
            key_private.write(keys.export_key(format='PEM', passphrase=self.password, protection='PBKDF2WithHMAC-SHA512AndAES256-CBC', prot_params={'iteration_count':131072}))
    
    #Метод шифрования синхронного ключа с помощью асинхронного шифрования
    def synchronous_key_encryption(self):
        with open(self.sync_key_path, 'rb') as sync_key:
            sync_key = sync_key.read()
        with open(self.async_public_key_path, 'rb') as async_key:
            async_key = RSA.import_key(async_key.read(), self.password)
        
        encrypt = PKCS1_OAEP.new(async_key)
        encrypt = encrypt.encrypt(sync_key)
        with open(self.sync_key_path, 'wb') as sync_key:
            sync_key.write(encrypt)

Все остальное шифрование строится так:

открыть зашифрованный синхронный ключ --> расшифровать приватным ключом RSA --> зашифровать данные.

Так как все данные хранятся локально, в качестве СУБД был использован sqlite3. Все данные для входа пользователя хранятся в виде хэшэй с солью. Записи паролей состоят из несколько полей: имя записи, имя сайта/сервиса, логин, почта, пароль. В первой версии я шифровал только почту и пароль. Сейчас это кажется спорным решением: название сервиса и логин тоже могут раскрывать чувствительную информацию о пользователе. Поэтому в следующих версиях логичнее шифровать всю запись целиком, кроме технических идентификаторов.

#Генерация соли и добавление соли к данным пользователя
    def sault_func(self, user_name:str, user_password:str, sault:bytes=get_random_bytes(32)):
        user_name = bytes(user_name, encoding="utf-8")
        user_password = bytes(user_password, encoding="utf-8")
        sault_user_name = sault[:len(sault)//2]+user_name+sault[len(sault)//2:]
        sault_user_password = sault[:len(sault)//2]+user_password+sault[len(sault)//2:]
        
        return {"sault": sault, "sault_user_name": sault_user_name, "sault_user_password": sault_user_password}
      

Помимо этого ключи RSA имеют пароль который пользователь задает при входе в систему. Одно из самых спорных решений — временное хранение пользовательского пароля через keyring. С точки зрения удобства это помогало не спрашивать пароль повторно, но с точки зрения модели угроз решение выглядит сомнительно. Сейчас я бы лучше хранил не сам пароль, а производный ключ, полученный через KDF, и очищал бы его из памяти после завершения работы.

class CachePassword:
    def __init__(self, password=None, name_service=None, time_del_pwd=0):
        self.PASSWORD = password
        self.NAME_SERVICE = name_service
        self.time_del_pwd = time_del_pwd
        self.user_name = socket.gethostname()
    
    #Метод помещения данных в кэш
    def set_password(self):
        keyring.set_password(self.NAME_SERVICE, self.user_name, self.PASSWORD)
    
    #Метод выдачи данных из кэша
    def get_password(self):
        return keyring.get_password(self.NAME_SERVICE, self.user_name)
    
    #Метод таймера удаления данных из кэша
    def del_timer(self):
        timer = threading.Timer(self.time_del_pwd, self.del_password)
        timer.daemon = True
        timer.start()
    
    #Метод удаления данных из кэша
    def del_password(self):
        keyring.delete_password(self.NAME_SERVICE, self.user_name)

Функционал

Из базовых функций уже понятно что: шифрование, реализация регистрации и входа в систему, удобное расположение записей

Интерфейс программы

Интерфейс программы

Поговорим про хоть какое-то удобство.

Под кнопкой «+» у нас скрывается добавление записей и функция генерации пароля. Генератор не претендует на идеальность, но позволяет создавать пароли, которые обычно лучше ручных пользовательских вариантов.

Интерфейс добавления записи

Интерфейс добавления записи
#Класс генирации паролей
class GenerationPassword:
    def __init__(self, len_password:int):
        self.len_password = len_password
        self.chars_number = ''.join(string.printable.split())
    
    #Метод валидации пароля
    def validation(self, user_password):
        if not bool(re.search("[a-z]", user_password)):
            return False
        if not bool(re.search("[A-Z]", user_password)):
            return False
        if not bool(re.search("[0-9]", user_password)):
            return False
        if not bool(re.search("["+ re.escape(string.punctuation)+ "]", user_password)):
            return False
        
        return True
    
    #Метод генирации пароля
    def generation_password(self):
        while True:
            password = ''.join([secrets.choice(self.chars_number) for _ in range(self.len_password)])
            if self.validation(user_password=password):
                return password

Под стрелкой направленной вниз — импорт паролей из csv файла. Можно импортировать пароли из браузера:

Импорт из csv

Импорт из csv

Соответственно стрелка вверх — экспорт в csv. Можно использовать, если хотите перенести все в браузер или хотите перенести пароли на новую систему.

Экспорт в csv

Экспорт в csv

Также есть поиск по ключевым словам и функция удаления записи.

Есть также функция ответственная за проверку вводимого пароля при регистрации. Она легкая, но от нее больше и не требуется. Была мысль освободить пользователя от обязательности ввода «безопасного» пароля, но было решено добавить. Если кто‑то решит что это лишнее для него, может смело вырезать.

#Проверка валидации данных
    def validation(self):
        if not self.user_data or self.user_name in self.user_password:
            return False
        if not len(self.user_password) >= 8:
            return False
        if bool(re.search("[ ]", self.user_password)):
            return False
        if bool(re.search("[а-яА-ёЁ]", self.user_data)):
            return False
        if not bool(re.search("[a-z]", self.user_password)):
            return False
        if not bool(re.search("[A-Z]", self.user_password)):
            return False
        if not bool(re.search("[0-9]", self.user_password)):
            return False
        if not bool(re.search("[!-_#@%$*?/|&)}{<>+='(,)^:;№%*.]", self.user_password)):
            return False
        
        return True

Также есть функция удаления записи)

Сложности с которыми столкнулся

Большинство сложностей были касаемо работы с интерфейсом, но это все мелочи которые никак не влияют на функционал.

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

Заключение

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

В планах еще много что написать, так как многие сервисы в россии нормально не работают, а их альтернативами пользоваться никто не хочет. Исходный код проекта открыт. Ссылку на репозиторий оставлю в конце статьи: буду рад технической критике, замечаниям по архитектуре и предложениям по безопасности. Пока деньги не собираю, поэтому просто подписывайтесь на тгк, github и заходите на мой сайт, если конечно этого хотите.

Если есть что‑то что вам интересно, но я про это не рассказал, то задавайте вопросы.

Автор: Cheef44

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js