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

от автора

Предисловие

Это не полноценный гайд по созданию менеджера паролей и не рекомендация использовать мой подход в 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 и заходите на мой сайт, если конечно этого хотите.

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

ссылка на оригинал статьи https://habr.com/ru/articles/1036412/