一个简单的本地密码存储

第一次使用时需设置主密码,恢复密码,密保,感觉还马马虎虎,分享给有需要的朋友!

代码如下:

import tkinter as tk
from tkinter import ttk
import json
import os
import shutil
import random
import string
import uuid
import base64
import hashlib
from datetime import datetime
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
import getpass

# ====================== 路径配置 ======================
BASE_DIR = os.path.join(os.path.expanduser("~"), ".password_manager")
DATA_DIR = os.path.join(BASE_DIR, "data")
BACKUP_DIR = os.path.join(BASE_DIR, "backups")

for d in [BASE_DIR, DATA_DIR, BACKUP_DIR]:
    os.makedirs(d, exist_ok=True)

DATA_FILE = os.path.join(DATA_DIR, "pm_data.enc")
ANSWER_HASH_FILE = os.path.join(DATA_DIR, "answer_hash.txt")
SECURITY_QUESTION_FILE = os.path.join(DATA_DIR, "security_question.txt")
SALT_FILE = os.path.join(DATA_DIR, "salt.bin")
RECOVERY_KEY_FILE = os.path.join(DATA_DIR, "recovery_key.enc")

# ====================== 密码存储核心 ======================
class SafePasswordStore:
    def __init__(self):
        self.items = []
        self.key = None  # 当前使用的密钥
        self.question = ""
        self.answer = ""
        self.recovery_key = None

    def derive_key(self, pwd):
        """派生密钥(使用随机salt)"""
        if not os.path.exists(SALT_FILE):
            salt = os.urandom(16)
            with open(SALT_FILE, "wb") as f:
                f.write(salt)
        else:
            with open(SALT_FILE, "rb") as f:
                salt = f.read()
        
        kdf = PBKDF2HMAC(
            algorithm=hashes.SHA256(),
            length=32,
            salt=salt,
            iterations=480000
        )
        return base64.urlsafe_b64encode(kdf.derive(pwd.encode()))

    def hash_answer(self, answer):
        """生成答案的SHA256哈希(使用salt)"""
        with open(SALT_FILE, "rb") as f:
            salt = f.read()
        return hashlib.pbkdf2_hmac('sha256', answer.encode('utf-8'), salt, 100000).hex()

    def save_recovery_key(self, main_password, recovery_password):
        """保存恢复密钥(用恢复密码加密主密码派生的密钥)"""
        try:
            # 用主密码派生密钥(key1)
            key1 = self.derive_key(main_password)
            # 用恢复密码派生密钥(key2)
            key2 = self.derive_key(recovery_password)
            fernet = Fernet(key2)
            enc_key1 = fernet.encrypt(key1)
            
            with open(RECOVERY_KEY_FILE, "wb") as f:
                f.write(enc_key1)
            return True
        except Exception as e:
            print(f"保存恢复密钥失败: {e}")
            return False

    def recover_master_key(self, recovery_password):
        """使用恢复密码恢复原始密钥(即主密码派生的密钥)"""
        try:
            # 读取加密的key1
            with open(RECOVERY_KEY_FILE, "rb") as f:
                enc_key1 = f.read()
            
            # 使用恢复密码派生密钥
            key2 = self.derive_key(recovery_password)
            fernet = Fernet(key2)
            original_key = fernet.decrypt(enc_key1)
            
            return original_key
        except Exception as e:
            print(f"恢复密钥失败: {e}")
            return None

    def manual_backup(self):
        try:
            if not os.path.exists(DATA_FILE):
                return False
            ts = datetime.now().strftime("%Y%m%d_%H%M%S")
            bk_path = os.path.join(BACKUP_DIR, f"pm_data_{ts}.enc")
            shutil.copyfile(DATA_FILE, bk_path)
            return True
        except:
            return False

    def save_all(self):
        try:
            data = json.dumps(self.items, ensure_ascii=False).encode("utf-8")
            enc = Fernet(self.key).encrypt(data)
            with open(DATA_FILE, "wb") as f:
                f.write(enc)
        except Exception as e:
            print(f"保存失败: {e}")
            pass

    def load_data(self, pwd):
        try:
            key = self.derive_key(pwd)
            with open(DATA_FILE, "rb") as f:
                raw = Fernet(key).decrypt(f.read())
            self.items = json.loads(raw.decode("utf-8"))
            self.key = key  # 更新当前密钥
            return True
        except Exception as e:
            print(f"加载失败: {e}")
            self.items = []
            self.key = None
            return False

    def create_new_db(self, pwd, question, answer, recovery_pwd=None):
        # 确保salt存在(首次创建时生成)
        if not os.path.exists(SALT_FILE):
            salt = os.urandom(16)
            with open(SALT_FILE, "wb") as f:
                f.write(salt)
        
        # 生成答案哈希
        answer_hash = self.hash_answer(answer)
        
        self.question = question.strip()
        self.answer = answer.strip()
        self.key = self.derive_key(pwd)
        self.items = [{
            "type": "system",
            "question": self.question,
            "answer": self.answer,
            "id": "system_config"
        }]
        self.save_all()
        
        # 保存密保问题和答案哈希
        with open(SECURITY_QUESTION_FILE, "w", encoding="utf-8") as f:
            f.write(self.question)
        
        with open(ANSWER_HASH_FILE, "w", encoding="utf-8") as f:
            f.write(answer_hash)
        
        # 保存恢复密钥(如果提供了恢复密码)
        if recovery_pwd:
            self.save_recovery_key(pwd, recovery_pwd)

    def verify_answer(self, ans):
        """验证答案(用于主密码解密后的答案比对)"""
        return ans.strip() == self.answer

    def verify_answer_hash(self, ans):
        """验证答案哈希(用于找回密码时验证)"""
        try:
            with open(ANSWER_HASH_FILE, "r", encoding="utf-8") as f:
                stored_hash = f.read().strip()
            input_hash = self.hash_answer(ans)
            return input_hash == stored_hash
        except:
            return False

    def add_item(self, site, user, pwd):
        item_id = str(uuid.uuid4())
        self.items.append({
            "type": "item",
            "site": site,
            "user": user,
            "pwd": pwd,
            "id": item_id
        })
        self.save_all()

    def update_item(self, item_id, site, user, pwd):
        for it in self.items:
            if it.get("id") == item_id and it.get("type") == "item":
                it["site"] = site
                it["user"] = user
                it["pwd"] = pwd
                break
        self.save_all()

    def delete_item(self, item_id):
        self.items = [it for it in self.items if it.get("id") != item_id]
        self.save_all()

    def generate_pwd(self, length=16):
        """生成随机密码"""
        chars = string.ascii_letters + string.digits + "!@#$%^&*"
        return ''.join(random.choice(chars) for _ in range(length))

# ====================== 主界面 ======================
class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("密码管理器")
        self.geometry("750x550")
        self.resizable(False, False)
        self.safe = SafePasswordStore()  # 初始化安全存储
        self.style()
        self.show_login()

    def style(self):
        style = ttk.Style()
        style.configure("Treeview.Heading", font=("微软雅黑", 10, "bold"))

    def clear(self):
        for w in self.winfo_children():
            w.destroy()

    def create_modal(self, w, h):
        self.update_idletasks()
        win = tk.Toplevel(self)
        win.geometry(f"{w}x{h}")
        win.resizable(0, 0)
        win.transient(self)
        win.grab_set()
        main_x = self.winfo_x()
        main_y = self.winfo_y()
        main_w = self.winfo_width()
        main_h = self.winfo_height()
        x = main_x + (main_w - w) // 2
        y = main_y + (main_h - h) // 2
        win.geometry(f"+{x}+{y}")
        return win

    def msg(self, title, txt):
        m = self.create_modal(400, 160)
        m.title(title)
        tk.Label(m, text=txt, wraplength=350).pack(pady=25)
        ttk.Button(m, text="确定", command=m.destroy).pack()
        m.wait_window()

    def confirm(self, title, txt):
        res = False
        m = self.create_modal(400, 170)
        m.title(title)
        tk.Label(m, text=txt, wraplength=350).pack(pady=20)
        def ok(): nonlocal res; res=True; m.destroy()
        frm = tk.Frame(m)
        frm.pack(pady=5)
        ttk.Button(frm, text="确定", command=ok, width=8).grid(row=0, column=0, padx=10)
        ttk.Button(frm, text="取消", command=m.destroy, width=8).grid(row=0, column=1, padx=10)
        m.wait_window()
        return res

    def input(self, title, prompt, show=""):
        val = ""
        m = self.create_modal(400, 190)
        m.title(title)
        tk.Label(m, text=prompt).pack(pady=15)
        v = tk.StringVar()
        ttk.Entry(m, textvariable=v, show=show, width=30).pack(pady=5)
        def ok(): nonlocal val; val=v.get().strip(); m.destroy()
        ttk.Button(m, text="确定", command=ok).pack(pady=10)
        m.wait_window()
        return val

    def show_login(self):
        self.clear()
        main = tk.Frame(self, padx=40, pady=60)
        main.pack(expand=True)
        tk.Label(main, text="密码管理器", font=("微软雅黑", 18, "bold")).pack(pady=12)
        tk.Label(main, text="请输入主密码").pack()
        self.pwd_entry = ttk.Entry(main, show="*", width=28, font=("", 11))
        self.pwd_entry.pack(pady=8)
        self.pwd_entry.focus()
        self.pwd_entry.bind("<Return>", lambda e: self.do_login())

        frm = tk.Frame(main)
        frm.pack(pady=10)
        ttk.Button(frm, text="登录", width=12, command=self.do_login).grid(row=0, column=0, padx=5)
        ttk.Button(frm, text="忘记密码", width=12, command=self.find_pwd).grid(row=0, column=1, padx=5)

        copyright_label = tk.Label(self, text="© 2025 gihut.com All Rights Reserved", anchor="se", fg="#888888", font=("Arial", 9))
        copyright_label.place(relx=1.0, rely=1.0, anchor="se", x=-10, y=-5)

    def do_login(self):
        pwd = self.pwd_entry.get().strip()
        if not pwd:
            self.msg("提示", "请输入主密码")
            return

        # 检查数据库文件是否存在
        if not os.path.exists(DATA_FILE):
            # 数据库不存在,提示创建新数据库
            self.msg("提示", "数据库不存在,请设置主密码创建新数据库")
            self.show_create_db()
            return

        if self.safe.load_data(pwd):
            self.show_main()
        else:
            self.msg("错误", "主密码错误!")

    def show_create_db(self):
        self.clear()
        main = tk.Frame(self, padx=40, pady=60)
        main.pack(expand=True)
        tk.Label(main, text="创建新数据库", font=("微软雅黑", 18, "bold")).pack(pady=12)
        tk.Label(main, text="设置主密码和密保问题").pack(pady=5)

        tk.Label(main, text="主密码(至少4位)").pack()
        self.new_pwd_entry = ttk.Entry(main, show="*", width=28, font=("", 11))
        self.new_pwd_entry.pack(pady=8)
        self.new_pwd_entry.focus()
        self.new_pwd_entry.bind("<Return>", lambda e: self.create_db())

        tk.Label(main, text="恢复密码(至少8位,用于忘记主密码时恢复)").pack()
        self.recovery_pwd_entry = ttk.Entry(main, show="*", width=28, font=("", 11))
        self.recovery_pwd_entry.pack(pady=8)
        tk.Label(main, text="(请安全保存此密码!)", fg="red", font=("Arial", 8)).pack()

        tk.Label(main, text="密保问题(例如:您小学的班主任名字?)").pack()
        self.question_entry = ttk.Entry(main, width=28, font=("", 11))
        self.question_entry.pack(pady=8)

        tk.Label(main, text="密保答案").pack()
        self.answer_entry = ttk.Entry(main, show="*", width=28, font=("", 11))
        self.answer_entry.pack(pady=8)

        frm = tk.Frame(main)
        frm.pack(pady=10)
        ttk.Button(frm, text="创建", width=12, command=self.create_db).grid(row=0, column=0, padx=5)
        ttk.Button(frm, text="返回", width=12, command=self.show_login).grid(row=0, column=1, padx=5)

    def create_db(self):
        pwd = self.new_pwd_entry.get().strip()
        recovery_pwd = self.recovery_pwd_entry.get().strip()
        question = self.question_entry.get().strip()
        answer = self.answer_entry.get().strip()

        if not pwd or len(pwd) < 4:
            self.msg("提示", "主密码不能为空且长度≥4")
            return
        if not question or not answer:
            self.msg("提示", "密保问题和答案不能为空")
            return
        if recovery_pwd and len(recovery_pwd) < 8:
            self.msg("提示", "恢复密码至少需要8位")
            return

        self.safe.create_new_db(pwd, question, answer, recovery_pwd)
        self.show_main()

    def find_pwd(self):
        if not os.path.exists(DATA_FILE):
            self.msg("提示", "未创建密码库")
            return

        if not os.path.exists(SECURITY_QUESTION_FILE):
            self.msg("提示", "未设置密保问题,无法找回!")
            return

        # 检查是否设置了恢复密码
        if not os.path.exists(RECOVERY_KEY_FILE):
            self.msg("提示", "未设置恢复密码,无法找回!")
            return

        with open(SECURITY_QUESTION_FILE, "r", encoding="utf-8") as f:
            question = f.read().strip()

        user_answer = self.input("密保验证", f"密保问题:\n{question}\n\n请输入答案:")
        if not user_answer:
            return

        if not self.safe.verify_answer_hash(user_answer):
            self.msg("错误", "密保答案不正确!")
            return

        # 询问恢复密码
        recovery_pwd = self.input("恢复密码", "请输入恢复密码(用于忘记主密码时恢复)", show="*")
        if not recovery_pwd:
            return

        # 验证恢复密码
        if not self.safe.recover_master_key(recovery_pwd):
            self.msg("错误", "恢复密码不正确!")
            return

        # 提示设置新主密码
        new_main_pwd = self.input("新主密码", "请输入新主密码(至少4位)", show="*")
        if not new_main_pwd or len(new_main_pwd) < 4:
            self.msg("提示", "新主密码不能为空且长度≥4")
            return

        # 关键修复:这里不再使用self.safe.key,而是重新创建SafePasswordStore
        if self.save_new_master_password(new_main_pwd, recovery_pwd):
            self.msg("成功", "主密码已成功恢复!请使用新密码登录。")
            # 重要:重置SafePasswordStore对象,避免密钥残留
            self.safe = SafePasswordStore()
            self.show_login()
        else:
            self.msg("错误", "密码恢复失败,请重试。")

    def save_new_master_password(self, new_pwd, recovery_pwd):
        """保存新主密码(关键修复:重置SafePasswordStore对象)"""
        try:
            # 恢复原始密钥(主密码派生的密钥)
            original_key = self.safe.recover_master_key(recovery_pwd)
            if not original_key:
                return False
            
            # 用新主密码派生密钥
            new_key = self.safe.derive_key(new_pwd)
            
            # 重新加密所有数据
            with open(DATA_FILE, "rb") as f:
                encrypted_data = f.read()
            
            # 使用原始密钥解密
            decrypted_data = Fernet(original_key).decrypt(encrypted_data)
            
            # 用新密钥重新加密
            new_encrypted_data = Fernet(new_key).encrypt(decrypted_data)
            
            # 保存回文件
            with open(DATA_FILE, "wb") as f:
                f.write(new_encrypted_data)
            
            return True
        except Exception as e:
            print(f"保存新主密码失败: {e}")
            return False

    def manual_backup(self):
        if self.safe.manual_backup():
            self.msg("备份成功", "已生成时间戳备份文件")
        else:
            self.msg("备份失败", "请重试")

    def show_backup_list(self):
        backups = []
        for f in os.listdir(BACKUP_DIR):
            if f.endswith(".enc"):
                backups.append(f)

        backups.sort(key=lambda x: os.path.getmtime(os.path.join(BACKUP_DIR, x)), reverse=True)

        if not backups:
            self.msg("提示", "暂无备份")
            return

        m = self.create_modal(520, 420)
        m.title("备份列表(最新在上)")

        frame = tk.Frame(m)
        frame.pack(fill="both", expand=True, padx=10, pady=5)
        scroll = tk.Scrollbar(frame)
        scroll.pack(side="right", fill="y")
        listbox = tk.Listbox(frame, yscrollcommand=scroll.set, font=("微软雅黑", 10))
        listbox.pack(fill="both", expand=True)
        scroll.config(command=listbox.yview)

        for b in backups:
            listbox.insert("end", b)

        def restore():
            if not listbox.curselection():
                self.msg("提示", "请选择备份")
                return
            fn = listbox.get(listbox.curselection()[0])
            src = os.path.join(BACKUP_DIR, fn)

            if os.path.exists(DATA_FILE):
                ts = datetime.now().strftime("%Y%m%d%H%M%S")
                shutil.copy(DATA_FILE, os.path.join(DATA_DIR, f"恢复前_{ts}.enc"))

            shutil.copy(src, DATA_FILE)
            self.msg("成功", "恢复完成!请使用新密码登录。")
            m.destroy()
            self.show_login()

        ttk.Button(m, text="✅ 恢复选中", command=restore).pack(pady=8)

    def show_main(self):
        self.clear()
        top = tk.Frame(self, pady=10, padx=10)
        top.pack(fill="x")
        ttk.Button(top, text="➕ 添加", command=self.add_item).pack(side="left", padx=4)
        ttk.Button(top, text="✏️ 修改", command=self.edit_item).pack(side="left", padx=4)
        ttk.Button(top, text="➖ 删除", command=self.del_item).pack(side="left", padx=4)
        ttk.Button(top, text="🔄 刷新", command=self.refresh_list).pack(side="left", padx=4)
        ttk.Button(top, text="💾 手动备份", command=self.manual_backup).pack(side="left", padx=4)
        ttk.Button(top, text="📂 恢复备份", command=self.show_backup_list).pack(side="left", padx=4)
        ttk.Button(top, text="🚪 退出登录", command=self.show_login).pack(side="right", padx=4)

        tree_f = tk.Frame(self, padx=10, pady=5)
        tree_f.pack(fill="both", expand=True)
        self.tree = ttk.Treeview(tree_f, columns=("site","user","pwd"), show="headings", height=18)
        self.tree.heading("site", text="应用/网站")
        self.tree.heading("user", text="账号")
        self.tree.heading("pwd", text="密码(双击复制)")
        self.tree.column("site", width=220)
        self.tree.column("user", width=280)
        self.tree.column("pwd", width=200)
        self.tree.pack(fill="both", expand=True)
        self.tree.bind("<Double-1>", self.copy_pwd)
        self.refresh_list()

        copyright_label = tk.Label(self, text="© 2025 gihut.com All Rights Reserved", anchor="se", fg="#888888", font=("Arial", 9))
        copyright_label.place(relx=1.0, rely=1.0, anchor="se", x=-10, y=-5)

    def refresh_list(self):
        for i in self.tree.get_children():
            self.tree.delete(i)
        for it in self.safe.items:
            if it.get("type") == "item":
                self.tree.insert("", "end", iid=it["id"], values=(it["site"], it["user"], "●"*8))

    def copy_pwd(self, event):
        sel = self.tree.selection()
        if not sel:
            self.msg("提示", "请先选中一条记录")
            return
        for it in self.safe.items:
            if it["id"] == sel[0]:
                self.clipboard_clear()
                self.clipboard_append(it["pwd"])
                self.msg("成功", "密码已复制到剪贴板")
                break

    def del_item(self):
        sel = self.tree.selection()
        if not sel:
            self.msg("提示", "请选择一条记录")
            return
        if self.input("确认删除", "输入【删除】确认:") != "删除":
            self.msg("提示", "已取消")
            return
        self.safe.delete_item(sel[0])
        self.refresh_list()

    def add_item(self):
        m = self.create_modal(430, 260)
        m.title("添加密码")
        f = tk.Frame(m, padx=20, pady=20)
        f.pack(expand=True, fill="both")

        tk.Label(f, text="应用/网站").grid(row=0, column=0, sticky="w", pady=6)
        e1 = ttk.Entry(f, width=26)
        e1.grid(row=0, column=1, padx=5)
        e1.config(validate="key", validatecommand=(f.register(lambda s: len(s) <= 50), "%P"))

        tk.Label(f, text="账号").grid(row=1, column=0, sticky="w", pady=6)
        e2 = ttk.Entry(f, width=26)
        e2.grid(row=1, column=1, padx=5)
        e2.config(validate="key", validatecommand=(f.register(lambda s: len(s) <= 100), "%P"))

        tk.Label(f, text="密码").grid(row=2, column=0, sticky="w", pady=6)
        e3 = ttk.Entry(f, width=26)
        e3.grid(row=2, column=1, padx=5)
        e3.config(validate="key", validatecommand=(f.register(lambda s: len(s) <= 200), "%P"))

        def gen():
            e3.delete(0, tk.END)
            e3.insert(0, self.safe.generate_pwd(16))

        ttk.Button(f, text="生成", command=gen).grid(row=2, column=2)

        def save():
            s = e1.get().strip()
            u = e2.get().strip()
            p = e3.get().strip()
            if not s:
                self.msg("提示", "应用名称不能为空")
                return
            if len(s) > 50 or len(u) > 100 or len(p) > 200:
                self.msg("提示", "超出字符限制!")
                return
            self.safe.add_item(s, u, p)
            self.refresh_list()
            m.destroy()

        ttk.Button(f, text="保存", command=save).grid(row=3, column=0, columnspan=3, pady=12)

    def edit_item(self):
        sel = self.tree.selection()
        if not sel:
            self.msg("提示", "请选择")
            return
        item = None
        for it in self.safe.items:
            if it.get("id") == sel[0] and it.get("type") == "item":
                item = it
                break
        if not item:
            return

        m = self.create_modal(430, 260)
        m.title("修改密码")
        f = tk.Frame(m, padx=20, pady=20)
        f.pack(expand=True, fill="both")

        tk.Label(f, text="应用名称(最多50字)").grid(row=0, column=0, sticky="w", pady=6)
        e1 = ttk.Entry(f, width=26)
        e1.grid(row=0, column=1, padx=5)
        e1.config(validate="key", validatecommand=(f.register(lambda s: len(s) <= 50), "%P"))
        e1.insert(0, item["site"])

        tk.Label(f, text="账号(最多100字)").grid(row=1, column=0, sticky="w", pady=6)
        e2 = ttk.Entry(f, width=26)
        e2.grid(row=1, column=1, padx=5)
        e2.config(validate="key", validatecommand=(f.register(lambda s: len(s) <= 100), "%P"))
        e2.insert(0, item["user"])

        tk.Label(f, text="密码(最多200字)").grid(row=2, column=0, sticky="w", pady=6)
        e3 = ttk.Entry(f, width=26)
        e3.grid(row=2, column=1, padx=5)
        e3.config(validate="key", validatecommand=(f.register(lambda s: len(s) <= 200), "%P"))
        e3.insert(0, item["pwd"])

        def gen():
            e3.delete(0, tk.END)
            e3.insert(0, self.safe.generate_pwd(16))

        ttk.Button(f, text="生成", command=gen).grid(row=2, column=2)

        def save():
            s = e1.get().strip()
            u = e2.get().strip()
            p = e3.get().strip()
            if not s:
                self.msg("提示", "应用名称不能为空")
                return
            if len(s) > 50 or len(u) > 100 or len(p) > 200:
                self.msg("提示", "超出字符限制!")
                return
            self.safe.update_item(item["id"], s, u, p)
            self.refresh_list()
            m.destroy()

        ttk.Button(f, text="保存", command=save).grid(row=3, column=0, columnspan=3, pady=12)

if __name__ == "__main__":
    app = App()
    app.mainloop()


声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。
予人玫瑰,手有余香
共0人
还没有人赞赏,快来当第一个赞赏的人吧!

参与评论 (0)

登录后再参与讨论
Top