首页
学习
活动
专区
圈层
工具
发布
社区首页 >问答首页 >Python密码管理器

Python密码管理器
EN

Code Review用户
提问于 2019-04-30 00:49:13
回答 2查看 9.6K关注 0票数 12

我正在编写一个python密码管理器,而且我知道在存储密码方面有很多细节(别担心,我的不是纯文本)。我希望这个社区能帮助我改进风格、使用库或其他任何东西。任何和所有的指针都被欣然接受。

我在这里实现了一些想法:

  • 使用唯一的盐加密每个密码,即使在内存中也是如此
  • 当数据库长期存储时,使用唯一的盐类加密每个数据库。
  • 能够保存到数据库文件(自定义格式)
  • 能够从数据库文件(自定义格式)读取

我知道有很多服务已经在做这样的事情了,但是我想我应该让它转一下,去学习和享受乐趣。运行程序文件提供了一些如何使用库的示例。

由于这得到了很多关注,我最近的代码将保留在这个GitHub回购上。

跑步者:

代码语言:javascript
复制
import sys, os
from .passdb import PassDB

if __name__ == "__main__":
    a = PassDB()
    # print(a)
    a.password = "password"
    a.set_entry("user", "localhost", "sample_password")
    # print(a.enc_str())
    a_copy = PassDB.open_db(a.enc_str(), "password")
    # print(a_copy.password)
    if a_copy is not None:
        print(a_copy.get_entry("user@localhost"))
        print(a_copy.get_password("user@localhost"))
        a_copy.save_as("tmp.passdb", "sample Password")

passdb.py

代码语言:javascript
复制
import base64
import hashlib
import pandas
from Crypto import Random
from Crypto.Cipher import AES
import json
import re
from io import StringIO
import datetime


class PassDB(object):

    _valid_init_fields = ["data", "path", "password", "settings"]
    version = "Version 0.0.1"
    settings: dict
    data: pandas.DataFrame
    _defaults = {
        "salt_size": 64,
        "block_size": 32,  # Using AES256
        "enc_sample_content": "The provided password is correct",
        "salt": None,
        "path": None,
        "hash_depth": 9
    }

    _format = """### PYPASSMAN {version} ###
{settings}
### SAMPLE ###
{enc_sample}
### DATA ###
{data}
"""

    def __init__(self, *args, **kwargs):
        if len(args) > 3:
            raise TypeError("Too Many Arguments")
        if len(args) > 2:
            self.data = args[2]
        else:
            self.data = None
        if len(args) > 1:
            self.password = args[1]
        else:
            self.password = None
        if len(args) > 0:
            self.path = args[0]
        else:
            self.path = None

        for key, arg in kwargs.items():
            if key in self._valid_init_fields:
                setattr(self, key, arg)

        if self.data is None:
            self.data = pandas.DataFrame(
                columns=[
                    "account",
                    "hostname",
                    "salt",
                    "password",
                    "hash_depth",
                    "dateModified",
                    "dateCreated"
                    ]
                )

        if getattr(self, "settings", None) is None:
            self.settings = self._defaults.copy()
        if self.settings.get("salt", None) is None:
            self.settings["salt"] = base64.b64encode(Random.new().read(
                self.settings["salt_size"]
            )).decode("utf-8")

        for key in self._defaults.keys():
            if key not in self.settings:
                self.settings[key] = self._defaults[key]

    @classmethod
    def open_db(cls, raw, password):
        settings, sample, data = (*map(
            lambda string: string.strip(),
            re.split(r"###.*###\n", raw)[1:]
            ),)
        settings = json.loads(settings)
        sample = cls._decrypt(sample, password, settings["salt"], settings["hash_depth"])
        if not sample == settings["enc_sample_content"]:
            raise ValueError(
                "Cannot open PassDB: incorrect password provided")
        data = cls._decrypt(data, password, settings["salt"], settings["hash_depth"])
        data = pandas.read_csv(StringIO(data))
        output = cls(
            settings=settings,
            data=data,
            password=password
        )
        return output

    def save_as(self, path, password):
        settings_cp = self.settings.copy()
        settings_cp["path"] = path
        new_dict = self.__class__(
            data = self.data,
            path = path,
            password = password,
            settings = settings_cp
        )
        new_dict.save()
        return True

    def save(self):
        with open(self.path, "w+") as dest:
            enc_data = self._encrypt(
                self.data.to_csv(index_label="index"),
                self.password, self.settings["salt"],
                self.settings["hash_depth"]
            )
            enc_sample = self._encrypt(
                self.settings["enc_sample_content"],
                self.password, self.settings["salt"],
                self.settings["hash_depth"])
            dest.write(self._format.format(
                version=str(self.version),
                settings=json.dumps(self.settings),
                data=enc_data,
                enc_sample=enc_sample
            ))

    @classmethod
    def _encrypt(cls, raw, password, salt, hash_depth):
        raw = cls._pad(raw)
        iv = Random.new().read(AES.block_size)
        salt = base64.b64decode(salt)
        key = hashlib.sha256(
                str(password).encode() + salt
            ).digest()
        for i in range(hash_depth):
            key = hashlib.sha256(key + salt).digest()
        cipher = AES.new(key, AES.MODE_CBC, iv)
        return base64.b64encode(iv + cipher.encrypt(raw)).decode("utf-8")

    @classmethod
    def _decrypt(cls, enc, password, salt, hash_depth):
        enc = base64.b64decode(enc)
        iv = enc[:AES.block_size]
        salt = base64.b64decode(salt)
        key = hashlib.sha256(
                password.encode() + salt
            ).digest()
        for i in range(hash_depth):
            key = hashlib.sha256(key + salt).digest()

        cipher = AES.new(key, AES.MODE_CBC, iv)
        try:
            return cls._unpad(
                cipher.decrypt(
                    enc[AES.block_size:]
                )
            ).decode('utf-8')
        except UnicodeDecodeError:
            raise ValueError("Incorrect Password")

    @classmethod
    def _pad(cls, s):
        bs = cls._defaults["block_size"]
        return (
            s + (bs - len(s) % bs) *
            chr(bs - len(s) % bs)
            )

    @staticmethod
    def _unpad(s):
        return s[:-ord(s[len(s)-1:])]

    def enc_str(self):
        enc_data = self._encrypt(
                self.data.to_csv(index_label="index"),
                self.password, self.settings["salt"],
                self.settings["hash_depth"]
            )
        enc_sample = self._encrypt(
                self.settings["enc_sample_content"],
                self.password, self.settings["salt"],
                self.settings["hash_depth"]
            )
        return (self._format.format(
                version=str(self.version),
                enc_sample=enc_sample,
                settings=json.dumps(self.settings),
                data=enc_data
            ))

    def __str__(self):
        path = self.settings["path"]
        return "PassDB <{} entries{}>".format(
            len(self.data), 
            " at '{}'".format(path) if path is not None else "" 
        )

    def set_entry(self, *args):
        account, hostname, password = None, None, None
        if len(args) == 1:
            account, hostname_password = args[0].split("@")
            hostname, password, other = hostname_password.split(":")
        elif len(args) == 2:
            account_hostname, password = args
            account, hostname = account_hostname.split("@")
        elif len(args) == 3:
            account, hostname, password = args
        else:
            raise ValueError("""
PassDB.set_entry :: Too many arguments 
    usage(1): get_password(account, hostname, password)
    usage(2): get_password("{account}@{hostname}", password)
    usage(3): get_password("{account}@{hostname}:{password}") """
                )

        for char in (":", "@"):
            for item in account, hostname, password:
                if char in item:
                    raise ValueError("""
account, hostname, and password cannot contain colon (:) or at symbol (@)""")

        if len(self.data) > 0:
            for index, entry in self.data.iterrows():
                if entry["account"] == account and entry["hostname"] == hostname:
                    salt = base64.b64encode(Random.new().read(
                        self.settings["salt_size"]
                    )).decode("utf-8")
                    password = self._encrypt(
                        password, 
                        self.settings["salt"], 
                        salt, 
                        self.settings["hash_depth"]
                        )
                    self.data.loc[index] = (
                        account, hostname, 
                        salt, password, 
                        self.settings["hash_depth"],
                        str(datetime.datetime.utcnow().isoformat()),
                        str(datetime.datetime.utcnow().isoformat())
                    )
        else:
            salt = base64.b64encode(Random.new().read(
                self.settings["salt_size"]
            )).decode("utf-8")
            password = self._encrypt(
                password, 
                self.settings["salt"], 
                salt, 
                self.settings["hash_depth"]
                )
            self.data.loc[0] = (
                account,
                hostname,
                salt,
                password,
                self.settings["hash_depth"],
                str(datetime.datetime.utcnow().isoformat()),
                str(datetime.datetime.utcnow().isoformat())
            )

    def get_entry(self, *args):
        if len(args) == 1:
            account, hostname = args[0].split("@")
        elif len(args) == 2:
            account, hostname = args
        else:
            raise ValueError("""
PassDB.get_entry :: Too many arguments
    usage(1): get_entry(account, hostname)
    usage(2): get_entry("{account}@{hostname}")""")
        if(getattr(self, "password") is None):
            raise ValueError("Cannot get entry when PassDB instance password is None")
        if(len(self.data)) == 0:
            return None
        for index, entry in self.data.iterrows():
            if entry["account"] == account and entry["hostname"] == hostname:
                return entry
        return None

    def get_password(self, *args):
        if len(args) == 1:
            account, hostname = args[0].split("@")
        elif len(args) == 2:
            account, hostname = args
        else:
            raise ValueError("""
PassDB.get_password :: Too many arguments
    usage(1): get_password(account, hostname)
    usage(2): get_password("{account}@{hostname}")""")

        entry = self.get_entry(account, hostname)
        if isinstance(entry["password"], str):
            return self._decrypt(entry["password"], self.settings["salt"], entry["salt"], entry["hash_depth"])
        raise ValueError("Password for {account}@{hostname} in unexpected format".format(**entry))
```
代码语言:javascript
复制
EN

回答 2

Code Review用户

回答已采纳

发布于 2019-04-30 02:54:11

一些一般性建议:

  1. 运行程序应该使用析解析解析来解析参数。它绝对不应该硬编码密码。
  2. (object)在Python3类定义中是多余的。
  3. 我建议在黑、flake8和mypy中运行任何具有严格配置的Python代码,就像这样:flake8 doctest= true排除= .git max-复杂度=5 max-line-length = 120 .git= W503,E203 形象化 check_untyped_defs = true disallow_untyped_defs = true ignore_missing_imports = true no_implicit_optional = true warn_redundant_casts = true warn_unused_ignores = true warn_unused_ignores= true
  4. 您可以重用具有完全不同语义的变量名称。这对于理解代码正在做什么并遵循即使是琐碎的逻辑也是一个非常糟糕的想法。例如,settings = json.loads(settings)意味着设置最初是一个str,实际上是一个序列化的JSON对象,然后是一个dict。它们有完全不同的语义和交互模式。处理这一问题的最简单方法是将几乎每个变量都视为不可变变量,并根据变量的实际情况命名变量。例如,settings = json.loads(serialized_settings)
  5. 名称应该是描述性的,例如password_database = PasswordDatabase()
  6. 除非需要动态参数列表,否则不要使用*args**kwargs。与其对*args进行索引,不如使用命名参数。如果它们具有默认值,则应该在方法签名中进行。
  7. .get(foo, None)可以简化为.get(foo) -默认情况下,get()返回None
  8. 在绝大多数情况下,if foo is None可以改为更惯用的if foo
  9. 我强烈建议使用众所周知的开放格式,如KeePass格式来存储这些数据。
  10. 这不应该出现在那里:如果不是示例==设置,则为“enc_示例_内容”:引发ValueError(“无法打开PassDB:提供不正确的密码”)
  11. 有很多的编码和解码发生,这极大地混淆了状态,似乎没有必要在几个地方。
  12. 如果没有一个全面的测试套件,我不会相信这类代码。

请注意,我不是密码学家:

  1. 除非您正在哈希密码(在本例中您不想这样做),否则盐渍是没有意义的。除非有人纠正了这一点,否则我将不做任何其他关于盐渍是如何做的评论。
票数 19
EN

Code Review用户

发布于 2019-04-30 13:45:20

关于密码学的一些东西:

  • 您是否仍然使用未维护的PyCrypto库或新的PyCryptodome (一个维护的、大部分兼容的插入替换)?
  • 您正在正确地使用CBC模式(用于加密的随机IV ),这是很好的。
  • 数据未经身份验证--即使加密的数据也可以更改,而不可能检测到。您可以使用HMAC (基于哈希的消息身份验证代码)或AEAD (带有附加数据的身份验证加密)加密模式。
  • 您的密码派生函数有很好的想法( for + Salt),但仍然有点弱:默认情况下只有9轮对于今天的标准来说太少了。当派生函数应用与密码存储相同的思想时,请考虑使用这些概念:例如PBKDF2 (它包含在Python中)或Argon2 (最现代的)。
票数 5
EN
页面原文内容由Code Review提供。腾讯云小微IT领域专用引擎提供翻译支持
原文链接:

https://codereview.stackexchange.com/questions/219400

复制
相关文章

相似问题

领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档