passlocker-tk 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- passlocker_tk/__init__.py +3 -0
- passlocker_tk/__main__.py +8 -0
- passlocker_tk/dialogs/__init__.py +16 -0
- passlocker_tk/dialogs/about_dialog.py +88 -0
- passlocker_tk/dialogs/account_display_dialog.py +138 -0
- passlocker_tk/dialogs/accounts_table.py +65 -0
- passlocker_tk/dialogs/generate_password_dialog.py +209 -0
- passlocker_tk/dialogs/new_otp_account_dialog.py +94 -0
- passlocker_tk/dialogs/new_password_account_dialog.py +126 -0
- passlocker_tk/dialogs/new_totp_account_dialog.py +147 -0
- passlocker_tk/dialogs/password_dialog.py +48 -0
- passlocker_tk/gui.py +217 -0
- passlocker_tk-1.0.0.dist-info/METADATA +33 -0
- passlocker_tk-1.0.0.dist-info/RECORD +19 -0
- passlocker_tk-1.0.0.dist-info/WHEEL +5 -0
- passlocker_tk-1.0.0.dist-info/entry_points.txt +2 -0
- passlocker_tk-1.0.0.dist-info/licenses/LICENSE +19 -0
- passlocker_tk-1.0.0.dist-info/top_level.txt +2 -0
- tests/create_demo.py +18 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from .password_dialog import PasswordDialog
|
|
2
|
+
from .accounts_table import AccountsTable
|
|
3
|
+
from .new_password_account_dialog import NewPasswordAccountDialog
|
|
4
|
+
from .new_otp_account_dialog import NewOTPAccountDialog
|
|
5
|
+
from .new_totp_account_dialog import NewTOTPAccountDialog
|
|
6
|
+
from .about_dialog import AboutDialog
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"PasswordDialog",
|
|
10
|
+
"AccountsTable",
|
|
11
|
+
"NewPasswordAccountDialog",
|
|
12
|
+
"NewOTPAccountDialog",
|
|
13
|
+
"NewTOTPAccountDialog",
|
|
14
|
+
"AboutDialog",
|
|
15
|
+
"PasswordDialog",
|
|
16
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import customtkinter as ctk
|
|
2
|
+
import webbrowser
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AboutDialog(ctk.CTkToplevel):
|
|
6
|
+
def __init__(
|
|
7
|
+
self,
|
|
8
|
+
parent,
|
|
9
|
+
*,
|
|
10
|
+
title,
|
|
11
|
+
version,
|
|
12
|
+
author,
|
|
13
|
+
github_url,
|
|
14
|
+
description,
|
|
15
|
+
):
|
|
16
|
+
super().__init__(parent)
|
|
17
|
+
|
|
18
|
+
self.title("About")
|
|
19
|
+
self.resizable(False, False)
|
|
20
|
+
|
|
21
|
+
# Modal
|
|
22
|
+
self.transient(parent)
|
|
23
|
+
self.grab_set()
|
|
24
|
+
|
|
25
|
+
self._build_ui(
|
|
26
|
+
title=title,
|
|
27
|
+
version=version,
|
|
28
|
+
author=author,
|
|
29
|
+
github_url=github_url,
|
|
30
|
+
description=description,
|
|
31
|
+
)
|
|
32
|
+
self._center(parent)
|
|
33
|
+
|
|
34
|
+
self.bind("<Escape>", lambda _: self.destroy())
|
|
35
|
+
self.wait_window()
|
|
36
|
+
|
|
37
|
+
# --------------------
|
|
38
|
+
# UI
|
|
39
|
+
# --------------------
|
|
40
|
+
def _build_ui(self, *, title, version, author, github_url, description):
|
|
41
|
+
frame = ctk.CTkFrame(self)
|
|
42
|
+
frame.pack(padx=24, pady=24)
|
|
43
|
+
|
|
44
|
+
# Title
|
|
45
|
+
ctk.CTkLabel(frame, text=title, font=ctk.CTkFont(size=20, weight="bold")).pack(
|
|
46
|
+
anchor="w"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Version / Author
|
|
50
|
+
ctk.CTkLabel(frame, text=f"Version {version}\n{author}", justify="left").pack(
|
|
51
|
+
anchor="w", pady=(4, 12)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Description
|
|
55
|
+
ctk.CTkLabel(frame, text=description, justify="left", wraplength=420).pack(
|
|
56
|
+
anchor="w", pady=(0, 12)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# GitHub link
|
|
60
|
+
self._link(frame, text="Source code on GitHub", url=github_url).pack(
|
|
61
|
+
anchor="w", pady=(0, 6)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# License link
|
|
65
|
+
self._link(
|
|
66
|
+
frame,
|
|
67
|
+
text="Licensed under CC-BY-NC-SA-4.0",
|
|
68
|
+
url="https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode",
|
|
69
|
+
).pack(anchor="w", pady=(0, 16))
|
|
70
|
+
|
|
71
|
+
# Close button
|
|
72
|
+
ctk.CTkButton(frame, text="Close", width=120, command=self.destroy).pack(
|
|
73
|
+
anchor="e"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# --------------------
|
|
77
|
+
# Helpers
|
|
78
|
+
# --------------------
|
|
79
|
+
def _link(self, parent, *, text, url):
|
|
80
|
+
lbl = ctk.CTkLabel(parent, text=text, text_color="#4ea1ff", cursor="hand2")
|
|
81
|
+
lbl.bind("<Button-1>", lambda _: webbrowser.open(url))
|
|
82
|
+
return lbl
|
|
83
|
+
|
|
84
|
+
def _center(self, parent):
|
|
85
|
+
self.update_idletasks()
|
|
86
|
+
x = parent.winfo_x() + (parent.winfo_width() // 2) - (self.winfo_width() // 2)
|
|
87
|
+
y = parent.winfo_y() + (parent.winfo_height() // 2) - (self.winfo_height() // 2)
|
|
88
|
+
self.geometry(f"+{x}+{y}")
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import customtkinter as ctk
|
|
2
|
+
import tkinter as tk
|
|
3
|
+
from hashlib import sha256
|
|
4
|
+
from secrets import token_bytes
|
|
5
|
+
|
|
6
|
+
from passlocker.models.account import Account
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AccountDisplayDialog(ctk.CTkToplevel):
|
|
10
|
+
def __init__(self, parent, *, account: Account):
|
|
11
|
+
super().__init__(parent)
|
|
12
|
+
|
|
13
|
+
self.title("Password Record")
|
|
14
|
+
self.resizable(False, False)
|
|
15
|
+
|
|
16
|
+
self.account: Account = account
|
|
17
|
+
|
|
18
|
+
self._revealed = False
|
|
19
|
+
self.iv: bytes = b""
|
|
20
|
+
self.passhash: str = ""
|
|
21
|
+
|
|
22
|
+
# Modal behavior
|
|
23
|
+
self.transient(parent)
|
|
24
|
+
self.wait_visibility()
|
|
25
|
+
self.grab_set()
|
|
26
|
+
|
|
27
|
+
self._build_ui()
|
|
28
|
+
self._center(parent)
|
|
29
|
+
|
|
30
|
+
self.bind("<Escape>", lambda _: self.destroy())
|
|
31
|
+
self.wait_window()
|
|
32
|
+
|
|
33
|
+
# --------------------
|
|
34
|
+
# UI
|
|
35
|
+
# --------------------
|
|
36
|
+
def _build_ui(self):
|
|
37
|
+
container: ctk.CTkFrame = ctk.CTkFrame(self)
|
|
38
|
+
container.pack(padx=10, pady=10)
|
|
39
|
+
|
|
40
|
+
# Account
|
|
41
|
+
ctk.CTkLabel(container, text="Account").grid(
|
|
42
|
+
row=0, column=0, sticky="w", padx=5
|
|
43
|
+
)
|
|
44
|
+
ctk.CTkLabel(
|
|
45
|
+
container, text=self.account.account, font=ctk.CTkFont(weight="bold")
|
|
46
|
+
).grid(row=0, column=1, sticky="w")
|
|
47
|
+
|
|
48
|
+
# Username
|
|
49
|
+
ctk.CTkLabel(container, text="Username").grid(
|
|
50
|
+
row=1, column=0, sticky="w", padx=5
|
|
51
|
+
)
|
|
52
|
+
ctk.CTkLabel(container, text=self.account.username).grid(
|
|
53
|
+
row=1, column=1, sticky="w"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Created on
|
|
57
|
+
ctk.CTkLabel(container, text="Created").grid(
|
|
58
|
+
row=2, column=0, sticky="w", padx=5
|
|
59
|
+
)
|
|
60
|
+
ctk.CTkLabel(container, text=self.account.created_on).grid(
|
|
61
|
+
row=2, column=1, sticky="w"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Password
|
|
65
|
+
ctk.CTkLabel(container, text="Password").grid(
|
|
66
|
+
row=3, column=0, sticky="w", padx=5
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
self.password_var: ctk.StringVar = ctk.StringVar(value="••••••••")
|
|
70
|
+
self.password_label: ctk.CTkLabel = ctk.CTkLabel(
|
|
71
|
+
container, textvariable=self.password_var, font=ctk.CTkFont(size=14)
|
|
72
|
+
)
|
|
73
|
+
self.password_label.grid(row=3, column=1, sticky="w")
|
|
74
|
+
|
|
75
|
+
# Buttons
|
|
76
|
+
btn_frame: ctk.CTkFrame = ctk.CTkFrame(container, fg_color="transparent")
|
|
77
|
+
btn_frame.grid(row=4, column=1, sticky="w", pady=(10, 0))
|
|
78
|
+
|
|
79
|
+
self.reveal_btn: ctk.CTkButton = ctk.CTkButton(
|
|
80
|
+
btn_frame, text="Show Password", width=100, command=self.toggle_reveal
|
|
81
|
+
)
|
|
82
|
+
self.reveal_btn.grid(row=0, column=0, padx=(0, 10))
|
|
83
|
+
|
|
84
|
+
self.copy_btn: ctk.CTkButton = ctk.CTkButton(
|
|
85
|
+
btn_frame, text="Copy to Clipboard", width=100, command=self.copy_password
|
|
86
|
+
)
|
|
87
|
+
self.copy_btn.grid(row=0, column=1)
|
|
88
|
+
|
|
89
|
+
container.grid_columnconfigure(1, weight=1)
|
|
90
|
+
|
|
91
|
+
# --------------------
|
|
92
|
+
# Actions
|
|
93
|
+
# --------------------
|
|
94
|
+
def toggle_reveal(self):
|
|
95
|
+
self._revealed = not self._revealed
|
|
96
|
+
|
|
97
|
+
if self._revealed:
|
|
98
|
+
password: bytes = self.account.get_active_password()
|
|
99
|
+
self.password_var.set(password.decode("utf-8"))
|
|
100
|
+
self.reveal_btn.configure(text="Hide")
|
|
101
|
+
else:
|
|
102
|
+
self.password_var.set("••••••••")
|
|
103
|
+
self.reveal_btn.configure(text="Reveal")
|
|
104
|
+
|
|
105
|
+
def copy_password(self):
|
|
106
|
+
self.clipboard_clear()
|
|
107
|
+
password: bytes = self.account.get_active_password()
|
|
108
|
+
self.iv: bytes = token_bytes(16)
|
|
109
|
+
self.passhash: str = sha256(self.iv + password).hexdigest()
|
|
110
|
+
self.clipboard_append(password.decode("utf-8"))
|
|
111
|
+
self.update() # ensure clipboard is updated
|
|
112
|
+
self._schedule_clipboard_clear()
|
|
113
|
+
|
|
114
|
+
def _schedule_clipboard_clear(self, delay_ms=15000):
|
|
115
|
+
self.after(delay_ms, self._clear_clipboard)
|
|
116
|
+
|
|
117
|
+
def _clear_clipboard(self):
|
|
118
|
+
try:
|
|
119
|
+
checkhash: str = sha256(
|
|
120
|
+
self.iv + self.clipboard_get().encode("utf-8")
|
|
121
|
+
).hexdigest()
|
|
122
|
+
if checkhash == self.passhash:
|
|
123
|
+
self.clipboard_clear()
|
|
124
|
+
except tk.TclError:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
# --------------------
|
|
128
|
+
# Helpers
|
|
129
|
+
# --------------------
|
|
130
|
+
def _center(self, parent):
|
|
131
|
+
self.update_idletasks()
|
|
132
|
+
x: int = (
|
|
133
|
+
parent.winfo_x() + (parent.winfo_width() // 2) - (self.winfo_width() // 2)
|
|
134
|
+
)
|
|
135
|
+
y: int = (
|
|
136
|
+
parent.winfo_y() + (parent.winfo_height() // 2) - (self.winfo_height() // 2)
|
|
137
|
+
)
|
|
138
|
+
self.geometry(f"+{x}+{y}")
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import customtkinter as ctk
|
|
2
|
+
import tkinter as tk
|
|
3
|
+
from tkinter import ttk
|
|
4
|
+
from passlocker.models.account import Account
|
|
5
|
+
from .account_display_dialog import AccountDisplayDialog
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AccountsTable(ctk.CTkFrame):
|
|
9
|
+
def __init__(self, parent: ctk.CTk) -> None:
|
|
10
|
+
super().__init__(parent)
|
|
11
|
+
|
|
12
|
+
columns = ("account", "username", "type")
|
|
13
|
+
|
|
14
|
+
self.tree: ttk.Treeview = ttk.Treeview(
|
|
15
|
+
self, columns=columns, show="headings", height=10
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# Column definitions
|
|
19
|
+
self.tree.heading("account", text="Account")
|
|
20
|
+
self.tree.heading("username", text="Username")
|
|
21
|
+
self.tree.heading("type", text="Type")
|
|
22
|
+
|
|
23
|
+
self.tree.column("account", anchor="w", width=200)
|
|
24
|
+
self.tree.column("username", anchor="w", width=180)
|
|
25
|
+
self.tree.column("type", anchor="center", width=80)
|
|
26
|
+
|
|
27
|
+
# Scrollbar
|
|
28
|
+
scrollbar = ttk.Scrollbar(self, orient="vertical", command=self.tree.yview)
|
|
29
|
+
self.tree.configure(yscrollcommand=scrollbar.set)
|
|
30
|
+
|
|
31
|
+
# Layout
|
|
32
|
+
self.tree.grid(row=0, column=0, sticky="nsew")
|
|
33
|
+
scrollbar.grid(row=0, column=1, sticky="ns")
|
|
34
|
+
|
|
35
|
+
self.grid_rowconfigure(0, weight=1)
|
|
36
|
+
self.grid_columnconfigure(0, weight=1)
|
|
37
|
+
|
|
38
|
+
self.tree.bind("<Double-1>", self.on_open_account)
|
|
39
|
+
self._accounts_by_id: dict[int, Account] = {}
|
|
40
|
+
|
|
41
|
+
# --------------------
|
|
42
|
+
# Public API
|
|
43
|
+
# --------------------
|
|
44
|
+
def populate(self, accounts: list[Account]) -> None:
|
|
45
|
+
"""
|
|
46
|
+
accounts: iterable of objects or dicts
|
|
47
|
+
expected fields: account, username, type
|
|
48
|
+
"""
|
|
49
|
+
self.tree.delete(*self.tree.get_children())
|
|
50
|
+
|
|
51
|
+
for acc in accounts:
|
|
52
|
+
self.tree.insert(
|
|
53
|
+
"", "end", iid=id(acc), values=(acc.account, acc.username, acc.type)
|
|
54
|
+
)
|
|
55
|
+
self._accounts_by_id: dict[int, Account] = {id(acc): acc for acc in accounts}
|
|
56
|
+
|
|
57
|
+
def on_open_account(self, event: tk.Event) -> None:
|
|
58
|
+
selected = self.tree.selection()
|
|
59
|
+
if not selected:
|
|
60
|
+
return
|
|
61
|
+
item_id = int(selected[0])
|
|
62
|
+
account = self._accounts_by_id.get(item_id)
|
|
63
|
+
if account is None:
|
|
64
|
+
return
|
|
65
|
+
_ = AccountDisplayDialog(self, account=account)
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import customtkinter as ctk
|
|
2
|
+
from tkinter import ttk
|
|
3
|
+
import secrets
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class GeneratePasswordDialog(ctk.CTkToplevel):
|
|
7
|
+
def __init__(self, parent) -> None:
|
|
8
|
+
super().__init__(parent)
|
|
9
|
+
|
|
10
|
+
self.title("Generate Password")
|
|
11
|
+
self.resizable(False, False)
|
|
12
|
+
|
|
13
|
+
self.result: str | None = None
|
|
14
|
+
|
|
15
|
+
# Modal behavior
|
|
16
|
+
self.transient(parent)
|
|
17
|
+
self.wait_visibility()
|
|
18
|
+
self.grab_set()
|
|
19
|
+
|
|
20
|
+
self._build_ui()
|
|
21
|
+
self._center(parent)
|
|
22
|
+
|
|
23
|
+
self.bind("<Escape>", lambda _: self._cancel())
|
|
24
|
+
|
|
25
|
+
self.wait_window()
|
|
26
|
+
|
|
27
|
+
# --------------------
|
|
28
|
+
# UI
|
|
29
|
+
# --------------------
|
|
30
|
+
def _build_ui(self) -> None:
|
|
31
|
+
root = ctk.CTkFrame(self)
|
|
32
|
+
root.pack(padx=20, pady=20)
|
|
33
|
+
|
|
34
|
+
# ---- Controls ----
|
|
35
|
+
controls = ctk.CTkFrame(root)
|
|
36
|
+
controls.pack(fill="x")
|
|
37
|
+
|
|
38
|
+
# Algorithm selector
|
|
39
|
+
ctk.CTkLabel(controls, text="Algorithm").grid(row=0, column=0, sticky="w")
|
|
40
|
+
|
|
41
|
+
self.algorithm_var = ctk.StringVar(value="random")
|
|
42
|
+
self.algorithm_menu = ctk.CTkOptionMenu(
|
|
43
|
+
controls,
|
|
44
|
+
variable=self.algorithm_var,
|
|
45
|
+
values=["random", "memorable", "numeric"],
|
|
46
|
+
width=140,
|
|
47
|
+
)
|
|
48
|
+
self.algorithm_menu.grid(row=0, column=1, padx=(10, 20))
|
|
49
|
+
|
|
50
|
+
# Length selector
|
|
51
|
+
ctk.CTkLabel(controls, text="Length").grid(row=0, column=2, sticky="w")
|
|
52
|
+
|
|
53
|
+
self.length_var = ctk.IntVar(value=16)
|
|
54
|
+
self.length_slider = ctk.CTkSlider(
|
|
55
|
+
controls, from_=8, to=64, number_of_steps=56, command=self._on_length_change
|
|
56
|
+
)
|
|
57
|
+
self.length_slider.set(16)
|
|
58
|
+
self.length_slider.grid(row=0, column=3, padx=(10, 10))
|
|
59
|
+
|
|
60
|
+
self.length_label = ctk.CTkLabel(controls, text="16", width=32, anchor="e")
|
|
61
|
+
self.length_label.grid(row=0, column=4)
|
|
62
|
+
|
|
63
|
+
controls.grid_columnconfigure(3, weight=1)
|
|
64
|
+
|
|
65
|
+
# Generate button
|
|
66
|
+
self.generate_btn = ctk.CTkButton(root, text="Generate", command=self._generate)
|
|
67
|
+
self.generate_btn.pack(pady=(10, 10))
|
|
68
|
+
|
|
69
|
+
# ---- Table ----
|
|
70
|
+
table_frame = ctk.CTkFrame(root)
|
|
71
|
+
table_frame.pack()
|
|
72
|
+
|
|
73
|
+
self.tree = ttk.Treeview(
|
|
74
|
+
table_frame, columns=("password",), show="headings", height=10
|
|
75
|
+
)
|
|
76
|
+
self.tree.heading("password", text="Generated Password")
|
|
77
|
+
self.tree.column("password", anchor="w", width=320)
|
|
78
|
+
|
|
79
|
+
scrollbar = ttk.Scrollbar(
|
|
80
|
+
table_frame, orient="vertical", command=self.tree.yview
|
|
81
|
+
)
|
|
82
|
+
self.tree.configure(yscrollcommand=scrollbar.set)
|
|
83
|
+
|
|
84
|
+
self.tree.grid(row=0, column=0, sticky="nsew")
|
|
85
|
+
scrollbar.grid(row=0, column=1, sticky="ns")
|
|
86
|
+
|
|
87
|
+
table_frame.grid_rowconfigure(0, weight=1)
|
|
88
|
+
table_frame.grid_columnconfigure(0, weight=1)
|
|
89
|
+
|
|
90
|
+
# Double-click selection
|
|
91
|
+
self.tree.bind("<Double-1>", self._on_select)
|
|
92
|
+
|
|
93
|
+
# --------------------
|
|
94
|
+
# Actions
|
|
95
|
+
# --------------------
|
|
96
|
+
def _on_length_change(self, value):
|
|
97
|
+
self.length_label.configure(text=str(int(value)))
|
|
98
|
+
|
|
99
|
+
def _generate(self):
|
|
100
|
+
algorithm = self.algorithm_var.get()
|
|
101
|
+
length = self.length_var.get()
|
|
102
|
+
|
|
103
|
+
passwords = self.generate_passwords(algorithm, length, count=10)
|
|
104
|
+
self.tree.delete(*self.tree.get_children())
|
|
105
|
+
|
|
106
|
+
for pw in passwords:
|
|
107
|
+
self.tree.insert("", "end", values=(pw,))
|
|
108
|
+
|
|
109
|
+
def _on_select(self, event):
|
|
110
|
+
selection = self.tree.selection()
|
|
111
|
+
if not selection:
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
self.result = self.tree.item(selection[0], "values")[0]
|
|
115
|
+
self.destroy()
|
|
116
|
+
|
|
117
|
+
def _cancel(self):
|
|
118
|
+
self.result = None
|
|
119
|
+
self.destroy()
|
|
120
|
+
|
|
121
|
+
# --------------------
|
|
122
|
+
# Helpers
|
|
123
|
+
# --------------------
|
|
124
|
+
def _center(self, parent):
|
|
125
|
+
self.update_idletasks()
|
|
126
|
+
x = parent.winfo_x() + (parent.winfo_width() // 2) - (self.winfo_width() // 2)
|
|
127
|
+
y = parent.winfo_y() + (parent.winfo_height() // 2) - (self.winfo_height() // 2)
|
|
128
|
+
self.geometry(f"+{x}+{y}")
|
|
129
|
+
|
|
130
|
+
def generate_passwords(
|
|
131
|
+
self, algorithm: str, length: int, count: int = 10
|
|
132
|
+
) -> list[str]:
|
|
133
|
+
if algorithm == "memorable":
|
|
134
|
+
return [self.generate_memorable(length) for _ in range(count)]
|
|
135
|
+
elif algorithm == "random":
|
|
136
|
+
return [self.generate_random(length) for _ in range(count)]
|
|
137
|
+
elif algorithm == "numeric":
|
|
138
|
+
return [self.generate_numbers(length) for _ in range(count)]
|
|
139
|
+
else:
|
|
140
|
+
raise Exception(f"Unknown generation algorithm: {algorithm}")
|
|
141
|
+
|
|
142
|
+
def generate_memorable(self, length: int) -> str:
|
|
143
|
+
if length <= 8:
|
|
144
|
+
maxnum = 100
|
|
145
|
+
special = 0
|
|
146
|
+
elif length <= 16:
|
|
147
|
+
maxnum = 1000
|
|
148
|
+
special = 1
|
|
149
|
+
elif length <= 24:
|
|
150
|
+
maxnum = 10000
|
|
151
|
+
special = 2
|
|
152
|
+
else:
|
|
153
|
+
maxnum = 100000
|
|
154
|
+
special = 3
|
|
155
|
+
|
|
156
|
+
random_int: int = secrets.randbelow(maxnum)
|
|
157
|
+
random_special: str = "".join(
|
|
158
|
+
secrets.choice(
|
|
159
|
+
[
|
|
160
|
+
"!",
|
|
161
|
+
"@",
|
|
162
|
+
"#",
|
|
163
|
+
"$",
|
|
164
|
+
"%",
|
|
165
|
+
"^",
|
|
166
|
+
"&",
|
|
167
|
+
"*",
|
|
168
|
+
"(",
|
|
169
|
+
")",
|
|
170
|
+
"-",
|
|
171
|
+
"_",
|
|
172
|
+
"+",
|
|
173
|
+
"=",
|
|
174
|
+
"{",
|
|
175
|
+
"}",
|
|
176
|
+
"[",
|
|
177
|
+
"]",
|
|
178
|
+
":",
|
|
179
|
+
";",
|
|
180
|
+
"<",
|
|
181
|
+
">",
|
|
182
|
+
".",
|
|
183
|
+
",",
|
|
184
|
+
"?",
|
|
185
|
+
"/",
|
|
186
|
+
"~",
|
|
187
|
+
"`",
|
|
188
|
+
]
|
|
189
|
+
)
|
|
190
|
+
for i in range(special)
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
secret_sauce = f"{random_int}{random_special}"
|
|
194
|
+
password: str = ""
|
|
195
|
+
|
|
196
|
+
with open("/usr/share/dict/words") as f:
|
|
197
|
+
words: list[str] = [word.strip() for word in f]
|
|
198
|
+
genlen: int = 0
|
|
199
|
+
while genlen != length:
|
|
200
|
+
password = secret_sauce.join(secrets.choice(words) for _ in range(2))
|
|
201
|
+
genlen = len(password)
|
|
202
|
+
|
|
203
|
+
return password
|
|
204
|
+
|
|
205
|
+
def generate_random(self, length: int) -> str:
|
|
206
|
+
return secrets.token_urlsafe(32)[0:length]
|
|
207
|
+
|
|
208
|
+
def generate_numbers(self, length: int) -> str:
|
|
209
|
+
return "".join([str(secrets.choice(range(10))) for i in range(length)])
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import customtkinter as ctk
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class NewOTPAccountDialog(ctk.CTkToplevel):
|
|
5
|
+
def __init__(self, parent):
|
|
6
|
+
super().__init__(parent)
|
|
7
|
+
|
|
8
|
+
self.title("New Account")
|
|
9
|
+
self.resizable(False, False)
|
|
10
|
+
|
|
11
|
+
self.result = None
|
|
12
|
+
|
|
13
|
+
# Modal behavior
|
|
14
|
+
self.transient(parent)
|
|
15
|
+
self.grab_set()
|
|
16
|
+
|
|
17
|
+
self._build_ui()
|
|
18
|
+
self._center(parent)
|
|
19
|
+
|
|
20
|
+
self.bind("<Escape>", lambda _: self._cancel())
|
|
21
|
+
self.bind("<Return>", lambda _: self._ok())
|
|
22
|
+
|
|
23
|
+
self.wait_window()
|
|
24
|
+
|
|
25
|
+
# --------------------
|
|
26
|
+
# UI
|
|
27
|
+
# --------------------
|
|
28
|
+
def _build_ui(self):
|
|
29
|
+
frame = ctk.CTkFrame(self)
|
|
30
|
+
frame.pack(padx=20, pady=20)
|
|
31
|
+
|
|
32
|
+
# Variables
|
|
33
|
+
self.account_var = ctk.StringVar()
|
|
34
|
+
self.username_var = ctk.StringVar()
|
|
35
|
+
|
|
36
|
+
# Account name
|
|
37
|
+
ctk.CTkLabel(frame, text="Account").grid(row=0, column=0, sticky="w")
|
|
38
|
+
self.account_entry = ctk.CTkEntry(
|
|
39
|
+
frame, textvariable=self.account_var, width=260
|
|
40
|
+
)
|
|
41
|
+
self.account_entry.grid(row=0, column=1, pady=5)
|
|
42
|
+
self.account_entry.focus()
|
|
43
|
+
|
|
44
|
+
# Username
|
|
45
|
+
ctk.CTkLabel(frame, text="Username").grid(row=1, column=0, sticky="w")
|
|
46
|
+
ctk.CTkEntry(frame, textvariable=self.username_var, width=260).grid(
|
|
47
|
+
row=1, column=1, pady=5
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Password
|
|
51
|
+
ctk.CTkLabel(frame, text="Passwords").grid(row=2, column=0, sticky="w")
|
|
52
|
+
self.password_entry = ctk.CTkTextbox(frame, width=260, height=112)
|
|
53
|
+
self.password_entry.grid(row=2, column=1, pady=5)
|
|
54
|
+
|
|
55
|
+
# Buttons
|
|
56
|
+
btn_frame = ctk.CTkFrame(frame, fg_color="transparent")
|
|
57
|
+
btn_frame.grid(row=3, column=1, pady=(15, 0), sticky="e")
|
|
58
|
+
|
|
59
|
+
ctk.CTkButton(btn_frame, text="Cancel", width=100, command=self._cancel).grid(
|
|
60
|
+
row=0, column=0, padx=(0, 10)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
self.ok_btn = ctk.CTkButton(btn_frame, text="OK", width=100, command=self._ok)
|
|
64
|
+
self.ok_btn.grid(row=0, column=1)
|
|
65
|
+
|
|
66
|
+
frame.grid_columnconfigure(1, weight=1)
|
|
67
|
+
|
|
68
|
+
def _ok(self):
|
|
69
|
+
account = self.account_var.get().strip()
|
|
70
|
+
username = self.username_var.get().strip()
|
|
71
|
+
password = self.password_entry.get("0.0", ctk.END)
|
|
72
|
+
|
|
73
|
+
if not account or not password:
|
|
74
|
+
return # minimal validation; extend as needed
|
|
75
|
+
|
|
76
|
+
self.result = {
|
|
77
|
+
"account": account,
|
|
78
|
+
"username": username,
|
|
79
|
+
"password": password,
|
|
80
|
+
}
|
|
81
|
+
self.destroy()
|
|
82
|
+
|
|
83
|
+
def _cancel(self):
|
|
84
|
+
self.result = None
|
|
85
|
+
self.destroy()
|
|
86
|
+
|
|
87
|
+
# --------------------
|
|
88
|
+
# Helpers
|
|
89
|
+
# --------------------
|
|
90
|
+
def _center(self, parent):
|
|
91
|
+
self.update_idletasks()
|
|
92
|
+
x = parent.winfo_x() + (parent.winfo_width() // 2) - (self.winfo_width() // 2)
|
|
93
|
+
y = parent.winfo_y() + (parent.winfo_height() // 2) - (self.winfo_height() // 2)
|
|
94
|
+
self.geometry(f"+{x}+{y}")
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import customtkinter as ctk
|
|
2
|
+
from .generate_password_dialog import GeneratePasswordDialog
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class NewPasswordAccountDialog(ctk.CTkToplevel):
|
|
6
|
+
def __init__(self, parent):
|
|
7
|
+
super().__init__(parent)
|
|
8
|
+
|
|
9
|
+
self.title("New Account")
|
|
10
|
+
self.resizable(False, False)
|
|
11
|
+
|
|
12
|
+
self.result = None
|
|
13
|
+
|
|
14
|
+
# Modal behavior
|
|
15
|
+
self.transient(parent)
|
|
16
|
+
self.wait_visibility()
|
|
17
|
+
self.grab_set()
|
|
18
|
+
|
|
19
|
+
self._build_ui()
|
|
20
|
+
self._center(parent)
|
|
21
|
+
|
|
22
|
+
self.bind("<Escape>", lambda _: self._cancel())
|
|
23
|
+
self.bind("<Return>", lambda _: self._ok())
|
|
24
|
+
|
|
25
|
+
self.wait_window()
|
|
26
|
+
|
|
27
|
+
# --------------------
|
|
28
|
+
# UI
|
|
29
|
+
# --------------------
|
|
30
|
+
def _build_ui(self):
|
|
31
|
+
frame = ctk.CTkFrame(self)
|
|
32
|
+
frame.pack(padx=20, pady=20)
|
|
33
|
+
|
|
34
|
+
# Variables
|
|
35
|
+
self.account_var = ctk.StringVar()
|
|
36
|
+
self.username_var = ctk.StringVar()
|
|
37
|
+
self.password_var = ctk.StringVar()
|
|
38
|
+
|
|
39
|
+
# Account name
|
|
40
|
+
ctk.CTkLabel(frame, text="Account").grid(row=0, column=0, sticky="w")
|
|
41
|
+
self.account_entry = ctk.CTkEntry(
|
|
42
|
+
frame, textvariable=self.account_var, width=260
|
|
43
|
+
)
|
|
44
|
+
self.account_entry.grid(row=0, column=1, pady=5)
|
|
45
|
+
self.account_entry.focus()
|
|
46
|
+
|
|
47
|
+
# Username
|
|
48
|
+
ctk.CTkLabel(frame, text="Username").grid(row=1, column=0, sticky="w")
|
|
49
|
+
ctk.CTkEntry(frame, textvariable=self.username_var, width=260).grid(
|
|
50
|
+
row=1, column=1, pady=5
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Password
|
|
54
|
+
ctk.CTkLabel(frame, text="Password").grid(row=2, column=0, sticky="w")
|
|
55
|
+
self.password_entry = ctk.CTkEntry(
|
|
56
|
+
frame, textvariable=self.password_var, show="•", width=260
|
|
57
|
+
)
|
|
58
|
+
self.password_entry.grid(row=2, column=1, pady=5)
|
|
59
|
+
|
|
60
|
+
self.reveal_btn = ctk.CTkButton(
|
|
61
|
+
frame, text="Reveal", width=80, command=self._toggle_reveal
|
|
62
|
+
)
|
|
63
|
+
self.reveal_btn.grid(row=2, column=2, padx=(5, 0))
|
|
64
|
+
|
|
65
|
+
# Buttons
|
|
66
|
+
btn_frame = ctk.CTkFrame(frame, fg_color="transparent")
|
|
67
|
+
btn_frame.grid(row=3, column=1, pady=(15, 0), sticky="e")
|
|
68
|
+
|
|
69
|
+
ctk.CTkButton(btn_frame, text="Cancel", width=100, command=self._cancel).grid(
|
|
70
|
+
row=0, column=0, padx=(0, 10)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
self.ok_btn = ctk.CTkButton(btn_frame, text="OK", width=100, command=self._ok)
|
|
74
|
+
self.ok_btn.grid(row=0, column=1)
|
|
75
|
+
|
|
76
|
+
self.generate_btn = ctk.CTkButton(
|
|
77
|
+
btn_frame, text="Generate", width=100, command=self._generate
|
|
78
|
+
)
|
|
79
|
+
self.generate_btn.grid(row=0, column=2)
|
|
80
|
+
|
|
81
|
+
frame.grid_columnconfigure(1, weight=1)
|
|
82
|
+
|
|
83
|
+
# --------------------
|
|
84
|
+
# Actions
|
|
85
|
+
# --------------------
|
|
86
|
+
def _toggle_reveal(self):
|
|
87
|
+
if self.password_entry.cget("show"):
|
|
88
|
+
self.password_entry.configure(show="")
|
|
89
|
+
self.reveal_btn.configure(text="Hide")
|
|
90
|
+
else:
|
|
91
|
+
self.password_entry.configure(show="•")
|
|
92
|
+
self.reveal_btn.configure(text="Reveal")
|
|
93
|
+
|
|
94
|
+
def _ok(self):
|
|
95
|
+
account = self.account_var.get().strip()
|
|
96
|
+
username = self.username_var.get().strip()
|
|
97
|
+
password = self.password_var.get()
|
|
98
|
+
|
|
99
|
+
if not account or not password:
|
|
100
|
+
return # minimal validation; extend as needed
|
|
101
|
+
|
|
102
|
+
self.result = {
|
|
103
|
+
"account": account,
|
|
104
|
+
"username": username,
|
|
105
|
+
"password": password,
|
|
106
|
+
}
|
|
107
|
+
self.destroy()
|
|
108
|
+
|
|
109
|
+
def _cancel(self) -> None:
|
|
110
|
+
self.result = None
|
|
111
|
+
self.destroy()
|
|
112
|
+
|
|
113
|
+
def _generate(self) -> None:
|
|
114
|
+
dlg = GeneratePasswordDialog(self)
|
|
115
|
+
password: str | None = dlg.result
|
|
116
|
+
if password is not None:
|
|
117
|
+
self.password_var.set(password)
|
|
118
|
+
|
|
119
|
+
# --------------------
|
|
120
|
+
# Helpers
|
|
121
|
+
# --------------------
|
|
122
|
+
def _center(self, parent):
|
|
123
|
+
self.update_idletasks()
|
|
124
|
+
x = parent.winfo_x() + (parent.winfo_width() // 2) - (self.winfo_width() // 2)
|
|
125
|
+
y = parent.winfo_y() + (parent.winfo_height() // 2) - (self.winfo_height() // 2)
|
|
126
|
+
self.geometry(f"+{x}+{y}")
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# otpauth://totp/ACME%20Co:john.doe@email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30
|
|
2
|
+
import customtkinter as ctk
|
|
3
|
+
from urllib.parse import urlparse, parse_qs, unquote
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NewTOTPAccountDialog(ctk.CTkToplevel):
|
|
7
|
+
DEFAULTS = {
|
|
8
|
+
"algorithm": "SHA1",
|
|
9
|
+
"period": 30,
|
|
10
|
+
"digits": 6,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
def __init__(self, parent):
|
|
14
|
+
super().__init__(parent)
|
|
15
|
+
|
|
16
|
+
self.title("New TOTP Account")
|
|
17
|
+
self.resizable(False, False)
|
|
18
|
+
|
|
19
|
+
self.result = None
|
|
20
|
+
|
|
21
|
+
# Modal
|
|
22
|
+
self.transient(parent)
|
|
23
|
+
self.grab_set()
|
|
24
|
+
|
|
25
|
+
self._build_ui()
|
|
26
|
+
self._center(parent)
|
|
27
|
+
|
|
28
|
+
self.bind("<Return>", lambda _: self._ok())
|
|
29
|
+
self.bind("<Escape>", lambda _: self._cancel())
|
|
30
|
+
|
|
31
|
+
self.wait_window()
|
|
32
|
+
|
|
33
|
+
# --------------------
|
|
34
|
+
# UI
|
|
35
|
+
# --------------------
|
|
36
|
+
def _build_ui(self):
|
|
37
|
+
frame = ctk.CTkFrame(self)
|
|
38
|
+
frame.pack(padx=20, pady=20)
|
|
39
|
+
|
|
40
|
+
ctk.CTkLabel(frame, text="otpauth:// URL", anchor="w").grid(
|
|
41
|
+
row=0, column=0, sticky="w"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
self.url_var = ctk.StringVar()
|
|
45
|
+
|
|
46
|
+
self.entry = ctk.CTkEntry(frame, textvariable=self.url_var, width=420)
|
|
47
|
+
self.entry.grid(row=1, column=0, columnspan=2, pady=(5, 0))
|
|
48
|
+
self.entry.focus()
|
|
49
|
+
|
|
50
|
+
self.error_label = ctk.CTkLabel(frame, text="", text_color="red")
|
|
51
|
+
self.error_label.grid(row=2, column=0, columnspan=2, pady=(5, 0), sticky="w")
|
|
52
|
+
|
|
53
|
+
# Buttons
|
|
54
|
+
btn_frame = ctk.CTkFrame(frame, fg_color="transparent")
|
|
55
|
+
btn_frame.grid(row=3, column=1, pady=(15, 0), sticky="e")
|
|
56
|
+
|
|
57
|
+
ctk.CTkButton(btn_frame, text="Cancel", width=100, command=self._cancel).grid(
|
|
58
|
+
row=0, column=0, padx=(0, 10)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
ctk.CTkButton(btn_frame, text="OK", width=100, command=self._ok).grid(
|
|
62
|
+
row=0, column=1
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
frame.grid_columnconfigure(0, weight=1)
|
|
66
|
+
|
|
67
|
+
# --------------------
|
|
68
|
+
# Actions
|
|
69
|
+
# --------------------
|
|
70
|
+
def _ok(self):
|
|
71
|
+
url = self.url_var.get().strip()
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
self.result = self._parse_otpauth_url(url)
|
|
75
|
+
except ValueError as e:
|
|
76
|
+
self.error_label.configure(text=str(e))
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
self.destroy()
|
|
80
|
+
|
|
81
|
+
def _cancel(self):
|
|
82
|
+
self.result = None
|
|
83
|
+
self.destroy()
|
|
84
|
+
|
|
85
|
+
# --------------------
|
|
86
|
+
# Parsing / Validation
|
|
87
|
+
# --------------------
|
|
88
|
+
def _parse_otpauth_url(self, url: str) -> dict[str, str | int]:
|
|
89
|
+
parsed = urlparse(url)
|
|
90
|
+
|
|
91
|
+
if parsed.scheme != "otpauth":
|
|
92
|
+
raise ValueError("URL must start with otpauth://")
|
|
93
|
+
|
|
94
|
+
if parsed.netloc.lower() != "totp":
|
|
95
|
+
raise ValueError("Only otpauth://totp/ URLs are supported")
|
|
96
|
+
|
|
97
|
+
# Path: /AccountName:Username
|
|
98
|
+
label = unquote(parsed.path.lstrip("/"))
|
|
99
|
+
if ":" not in label:
|
|
100
|
+
raise ValueError("URL must include account name and username")
|
|
101
|
+
|
|
102
|
+
account_name, username = label.split(":", 1)
|
|
103
|
+
if not account_name or not username:
|
|
104
|
+
raise ValueError("Account name and username must be non-empty")
|
|
105
|
+
|
|
106
|
+
params: dict[str, list[str]] = parse_qs(parsed.query)
|
|
107
|
+
|
|
108
|
+
if "secret" not in params:
|
|
109
|
+
raise ValueError("URL is missing secret parameter")
|
|
110
|
+
|
|
111
|
+
secret = params["secret"][0]
|
|
112
|
+
if not secret:
|
|
113
|
+
raise ValueError("Secret must be non-empty")
|
|
114
|
+
|
|
115
|
+
# Optional parameters
|
|
116
|
+
algorithm: str = params.get("algorithm", [self.DEFAULTS["algorithm"]])[
|
|
117
|
+
0
|
|
118
|
+
].upper() # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue]
|
|
119
|
+
period: int = int(params.get("period", [self.DEFAULTS["period"]])[0])
|
|
120
|
+
digits: int = int(params.get("digits", [self.DEFAULTS["digits"]])[0])
|
|
121
|
+
|
|
122
|
+
if algorithm not in {"SHA1", "SHA256", "SHA512"}:
|
|
123
|
+
raise ValueError(f"Unsupported algorithm: {algorithm}")
|
|
124
|
+
|
|
125
|
+
if period <= 0:
|
|
126
|
+
raise ValueError("Period must be a positive integer")
|
|
127
|
+
|
|
128
|
+
if digits not in {6, 8}:
|
|
129
|
+
raise ValueError("Digits must be 6 or 8")
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
"account": account_name,
|
|
133
|
+
"username": username,
|
|
134
|
+
"secret": secret.upper(),
|
|
135
|
+
"algorithm": algorithm.lower(),
|
|
136
|
+
"period": period,
|
|
137
|
+
"digits": digits,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
# --------------------
|
|
141
|
+
# Helpers
|
|
142
|
+
# --------------------
|
|
143
|
+
def _center(self, parent):
|
|
144
|
+
self.update_idletasks()
|
|
145
|
+
x = parent.winfo_x() + (parent.winfo_width() // 2) - (self.winfo_width() // 2)
|
|
146
|
+
y = parent.winfo_y() + (parent.winfo_height() // 2) - (self.winfo_height() // 2)
|
|
147
|
+
self.geometry(f"+{x}+{y}")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import customtkinter as ctk
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PasswordDialog(ctk.CTkToplevel):
|
|
5
|
+
def __init__(self, parent, title="Enter Password"):
|
|
6
|
+
super().__init__(parent)
|
|
7
|
+
|
|
8
|
+
self.title(title)
|
|
9
|
+
self.resizable(False, False)
|
|
10
|
+
|
|
11
|
+
self.password = None
|
|
12
|
+
|
|
13
|
+
# Make dialog modal
|
|
14
|
+
self.transient(parent)
|
|
15
|
+
self.wait_visibility()
|
|
16
|
+
self.grab_set()
|
|
17
|
+
|
|
18
|
+
# Layout
|
|
19
|
+
self.label = ctk.CTkLabel(self, text="Password:")
|
|
20
|
+
self.label.pack(padx=20, pady=(20, 5))
|
|
21
|
+
|
|
22
|
+
self.var = ctk.StringVar()
|
|
23
|
+
self.entry = ctk.CTkEntry(self, textvariable=self.var, show="•", width=220)
|
|
24
|
+
self.entry.pack(padx=20, pady=5)
|
|
25
|
+
self.entry.focus()
|
|
26
|
+
|
|
27
|
+
self.button = ctk.CTkButton(self, text="OK", command=self.on_ok)
|
|
28
|
+
self.button.pack(padx=20, pady=(10, 20))
|
|
29
|
+
|
|
30
|
+
# Keyboard bindings
|
|
31
|
+
self.bind("<Return>", lambda _: self.on_ok())
|
|
32
|
+
self.bind("<Escape>", lambda _: self.destroy())
|
|
33
|
+
|
|
34
|
+
# Center on parent
|
|
35
|
+
self._center(parent)
|
|
36
|
+
|
|
37
|
+
# Wait until dialog is closed
|
|
38
|
+
self.wait_window()
|
|
39
|
+
|
|
40
|
+
def on_ok(self):
|
|
41
|
+
self.password = self.var.get()
|
|
42
|
+
self.destroy()
|
|
43
|
+
|
|
44
|
+
def _center(self, parent):
|
|
45
|
+
self.update_idletasks()
|
|
46
|
+
x = parent.winfo_x() + (parent.winfo_width() // 2) - (self.winfo_width() // 2)
|
|
47
|
+
y = parent.winfo_y() + (parent.winfo_height() // 2) - (self.winfo_height() // 2)
|
|
48
|
+
self.geometry(f"+{x}+{y}")
|
passlocker_tk/gui.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import tkinter as tk
|
|
2
|
+
from tkinter import ttk
|
|
3
|
+
from tkinter import Menu
|
|
4
|
+
from tkinter.ttk import Style
|
|
5
|
+
import customtkinter as ctk
|
|
6
|
+
|
|
7
|
+
from debounce import debounce
|
|
8
|
+
from passlocker.passlocker import PassLocker
|
|
9
|
+
from passlocker.models.account import Account
|
|
10
|
+
from .dialogs import (
|
|
11
|
+
AboutDialog,
|
|
12
|
+
AccountsTable,
|
|
13
|
+
NewOTPAccountDialog,
|
|
14
|
+
NewPasswordAccountDialog,
|
|
15
|
+
NewTOTPAccountDialog,
|
|
16
|
+
PasswordDialog,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from datetime import date
|
|
20
|
+
|
|
21
|
+
ctk.set_window_scaling(1.5) # UI scale
|
|
22
|
+
ctk.set_widget_scaling(2.0) # font + spacing scale
|
|
23
|
+
ctk.set_appearance_mode("dark")
|
|
24
|
+
ctk.set_default_color_theme("dark-blue")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PassLockerGui(ctk.CTk):
|
|
28
|
+
def __init__(self) -> None:
|
|
29
|
+
super().__init__()
|
|
30
|
+
|
|
31
|
+
self.pl: PassLocker = PassLocker(self.get_master_password)
|
|
32
|
+
|
|
33
|
+
self.set_style()
|
|
34
|
+
self.title("PassLocker GUI")
|
|
35
|
+
self.geometry("800x400")
|
|
36
|
+
self.default_font: ctk.CTkFont = ctk.CTkFont("Arial", size=14)
|
|
37
|
+
|
|
38
|
+
self.pnl: ctk.CTkFrame = ctk.CTkFrame(self)
|
|
39
|
+
label: ctk.CTkLabel = ctk.CTkLabel(
|
|
40
|
+
self.pnl, text="Search ", font=self.default_font
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
self.searchField: ctk.CTkEntry = ctk.CTkEntry(self.pnl, font=self.default_font)
|
|
44
|
+
self.searchField.bind("<KeyRelease>", self.search_changed)
|
|
45
|
+
|
|
46
|
+
label.pack(side=tk.LEFT)
|
|
47
|
+
self.searchField.pack(fill=tk.X)
|
|
48
|
+
self.pnl.pack(fill=tk.X)
|
|
49
|
+
|
|
50
|
+
self.create_menu()
|
|
51
|
+
self.list_passwords()
|
|
52
|
+
# self.createStatusBar()
|
|
53
|
+
# self.setStatusText("Welcome to PassLocker GUI!")
|
|
54
|
+
|
|
55
|
+
def set_style(self) -> None:
|
|
56
|
+
ctk.set_appearance_mode("dark")
|
|
57
|
+
ctk.set_default_color_theme("dark-blue")
|
|
58
|
+
|
|
59
|
+
# ui_font = tkfont.Font(family="Verdana", size=13)
|
|
60
|
+
style: Style = ttk.Style()
|
|
61
|
+
style.theme_use("default")
|
|
62
|
+
style.tk.call("tk", "scaling", 2.0)
|
|
63
|
+
|
|
64
|
+
style.configure(
|
|
65
|
+
"Treeview",
|
|
66
|
+
background="#242424",
|
|
67
|
+
fieldbackground="#242424",
|
|
68
|
+
foreground="#ffffff",
|
|
69
|
+
bordercolor="#444444",
|
|
70
|
+
borderwidth=0,
|
|
71
|
+
rowheight=32,
|
|
72
|
+
font=("Arial", 12),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
style.configure(
|
|
76
|
+
"Treeview.Heading",
|
|
77
|
+
background="#1f1f1f",
|
|
78
|
+
foreground="#ffffff",
|
|
79
|
+
font=("Arial", 12, "bold"),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
style.map(
|
|
83
|
+
"Treeview",
|
|
84
|
+
background=[("selected", "#1f6aa5")],
|
|
85
|
+
foreground=[("selected", "#ffffff")],
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def get_master_password(
|
|
89
|
+
self, prompt: str
|
|
90
|
+
) -> bytes: # pyright: ignore[reportReturnType]
|
|
91
|
+
password: str | None = None
|
|
92
|
+
while password is None:
|
|
93
|
+
dlg: PasswordDialog = PasswordDialog(self, prompt)
|
|
94
|
+
password: str | None = dlg.password
|
|
95
|
+
if password:
|
|
96
|
+
return password.encode("utf-8")
|
|
97
|
+
|
|
98
|
+
def create_menu(self) -> None:
|
|
99
|
+
menubar: Menu = tk.Menu(
|
|
100
|
+
self,
|
|
101
|
+
background="#242424",
|
|
102
|
+
foreground="#ffffff",
|
|
103
|
+
activebackground="#1f6aa5",
|
|
104
|
+
activeforeground="#ffffff",
|
|
105
|
+
font=("Verdana", 14),
|
|
106
|
+
)
|
|
107
|
+
file_menu: Menu = tk.Menu(
|
|
108
|
+
menubar,
|
|
109
|
+
tearoff=0,
|
|
110
|
+
background="#242424",
|
|
111
|
+
foreground="#ffffff",
|
|
112
|
+
activebackground="#1f6aa5",
|
|
113
|
+
activeforeground="#ffffff",
|
|
114
|
+
font=("Verdana", 14),
|
|
115
|
+
)
|
|
116
|
+
help_menu: Menu = tk.Menu(
|
|
117
|
+
menubar,
|
|
118
|
+
tearoff=0,
|
|
119
|
+
background="#242424",
|
|
120
|
+
foreground="#ffffff",
|
|
121
|
+
activebackground="#1f6aa5",
|
|
122
|
+
activeforeground="#ffffff",
|
|
123
|
+
font=("Verdana", 14),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
file_menu.add_command(
|
|
127
|
+
label="New Password Entry", command=self.on_new_password_entry
|
|
128
|
+
)
|
|
129
|
+
file_menu.add_command(label="New OTP Entry", command=self.on_new_otp_entry)
|
|
130
|
+
file_menu.add_command(label="New TOTP Entry", command=self.on_new_totp_entry)
|
|
131
|
+
|
|
132
|
+
menubar.add_cascade(label="File", menu=file_menu)
|
|
133
|
+
|
|
134
|
+
help_menu.add_command(label="About", command=self.on_about)
|
|
135
|
+
|
|
136
|
+
menubar.add_cascade(label="Help", menu=help_menu)
|
|
137
|
+
_ = self.config(menu=menubar)
|
|
138
|
+
|
|
139
|
+
def on_new_password_entry(self):
|
|
140
|
+
dlg: NewPasswordAccountDialog = NewPasswordAccountDialog(self)
|
|
141
|
+
account_data: dict[str, str] | None = dlg.result
|
|
142
|
+
if account_data:
|
|
143
|
+
created_on: str = date.today().strftime("%Y-%m-%d")
|
|
144
|
+
acc: Account = Account(
|
|
145
|
+
account_data["account"], account_data["username"], created_on
|
|
146
|
+
)
|
|
147
|
+
acc._passlocker = self.pl
|
|
148
|
+
acc.add_password(account_data["password"])
|
|
149
|
+
acc.save()
|
|
150
|
+
|
|
151
|
+
def on_new_otp_entry(self):
|
|
152
|
+
dlg: NewOTPAccountDialog = NewOTPAccountDialog(self)
|
|
153
|
+
account_data: dict[str, str] | None = dlg.result
|
|
154
|
+
if account_data:
|
|
155
|
+
created_on: str = date.today().strftime("%Y-%m-%d")
|
|
156
|
+
acc: Account = Account(
|
|
157
|
+
account_data["account"],
|
|
158
|
+
account_data["username"],
|
|
159
|
+
created_on,
|
|
160
|
+
type="otp",
|
|
161
|
+
)
|
|
162
|
+
acc._passlocker = self.pl
|
|
163
|
+
passwords: list[str] = account_data["password"].split("\n")
|
|
164
|
+
for password in passwords:
|
|
165
|
+
acc.add_password(password)
|
|
166
|
+
acc.save()
|
|
167
|
+
|
|
168
|
+
def on_new_totp_entry(self):
|
|
169
|
+
dlg: NewTOTPAccountDialog = NewTOTPAccountDialog(self)
|
|
170
|
+
account_data: dict[str, str | int] | None = dlg.result
|
|
171
|
+
created_on: str = date.today().strftime("%Y-%m-%d")
|
|
172
|
+
if account_data:
|
|
173
|
+
acc: Account = Account(
|
|
174
|
+
account=str(account_data["account"]),
|
|
175
|
+
username=str(account_data["username"]),
|
|
176
|
+
created_on=created_on,
|
|
177
|
+
active=0,
|
|
178
|
+
type="totp",
|
|
179
|
+
totp_epoch_start=0,
|
|
180
|
+
totp_time_interval=int(account_data["period"]),
|
|
181
|
+
totp_num_digits=int(account_data["digits"]),
|
|
182
|
+
totp_hash_algorithm=str(
|
|
183
|
+
account_data["algorithm"]
|
|
184
|
+
), # pyright: ignore[reportArgumentType]
|
|
185
|
+
)
|
|
186
|
+
acc._passlocker = self.pl
|
|
187
|
+
acc.add_password(str(account_data["secret"]))
|
|
188
|
+
acc.save()
|
|
189
|
+
|
|
190
|
+
def on_about(self):
|
|
191
|
+
_ = AboutDialog(
|
|
192
|
+
parent=self,
|
|
193
|
+
title="Passlocker-Tk",
|
|
194
|
+
version="1.0.0",
|
|
195
|
+
author="© 2026 C L",
|
|
196
|
+
github_url="https://github.com/chrislee35/passlocker-tk",
|
|
197
|
+
description=(
|
|
198
|
+
"Passlocker is a secure, offline-first password and OTP manager "
|
|
199
|
+
"designed for transparency, control, and local ownership of secrets."
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
@debounce(wait=0.3)
|
|
204
|
+
def search_changed(self, event: tk.Event):
|
|
205
|
+
filt: str = self.searchField.get()
|
|
206
|
+
accounts: list[Account] = [
|
|
207
|
+
acc
|
|
208
|
+
for acc in self.pl.accounts()
|
|
209
|
+
if filt in acc.account or filt in acc.username
|
|
210
|
+
]
|
|
211
|
+
self.table.populate(accounts)
|
|
212
|
+
|
|
213
|
+
def list_passwords(self) -> None:
|
|
214
|
+
self.table: AccountsTable = AccountsTable(self)
|
|
215
|
+
self.table.pack(fill="both", expand=True, padx=10, pady=10)
|
|
216
|
+
accounts: list[Account] = [acc for acc in self.pl.accounts()]
|
|
217
|
+
self.table.populate(accounts)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: passlocker-tk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Password Encryption GUI
|
|
5
|
+
Author-email: Chris Lee <github@chrislee.dhs.org>
|
|
6
|
+
License: CC-BY-NC-SA-4.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/chrislee35/passlocker-tk
|
|
8
|
+
Keywords: encryption,passwords,security
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Topic :: Utilities
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Requires-Python: >=3.12
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: passlocker>=1.0.2
|
|
16
|
+
Requires-Dist: customtkinter>=5.2.2
|
|
17
|
+
Requires-Dist: py-debounce>=0.1.2
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: build; extra == "dev"
|
|
20
|
+
Requires-Dist: twine; extra == "dev"
|
|
21
|
+
Requires-Dist: black; extra == "dev"
|
|
22
|
+
Requires-Dist: ruff; extra == "dev"
|
|
23
|
+
Requires-Dist: setuptools; extra == "dev"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# Overview
|
|
27
|
+
|
|
28
|
+
PassLocker-Tk provides a Tkinter interface around using [Passlocker](https://github.com/chrislee35/passlocker), a local-first password management program.
|
|
29
|
+
|
|
30
|
+
## Screenshot
|
|
31
|
+
|
|
32
|
+

|
|
33
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
passlocker_tk/__init__.py,sha256=fKkVqrrEEwG0GOF9LpJ2FE5NBi7MxWDnKg1jQ8qpQgo,59
|
|
2
|
+
passlocker_tk/__main__.py,sha256=rRT1jqVsDqw56iLVj1t4pxF3XQUD2TpOK4vHbek17_Y,150
|
|
3
|
+
passlocker_tk/gui.py,sha256=BQLba75OfO_0RS0ew-aNQ-uaSmNLxCI9GO00d5XyqnA,7237
|
|
4
|
+
passlocker_tk/dialogs/__init__.py,sha256=Uy2-JWV-lB8JKyd_zRj5v8dMCkm2hafPM4vBRJ86dQ8,490
|
|
5
|
+
passlocker_tk/dialogs/about_dialog.py,sha256=eHbroi1KqqgswhiQK3nKeeSAVO-61fhvej1BLPdSWEE,2451
|
|
6
|
+
passlocker_tk/dialogs/account_display_dialog.py,sha256=5WRDIhxR7ifNd9CgDBqEl0wfgLweFhnwQkqxB-Lg0hM,4440
|
|
7
|
+
passlocker_tk/dialogs/accounts_table.py,sha256=xKyRr3O5QUWroQv2Ocm9_buklK0AZ2QxvoFh6tbbqu0,2205
|
|
8
|
+
passlocker_tk/dialogs/generate_password_dialog.py,sha256=_u0jh-VDYbsCExl3-ig5csbIIt0VPKv_8-Wb5l6vRso,6370
|
|
9
|
+
passlocker_tk/dialogs/new_otp_account_dialog.py,sha256=AAdWKr0CTJEb4NEe9_9ze59e2UFOOJBLYtFfRgI-y2I,2865
|
|
10
|
+
passlocker_tk/dialogs/new_password_account_dialog.py,sha256=QBBZrPTjEjzoSK07rEPjEWtgqsb5RJEeNvAAJetErJc,3971
|
|
11
|
+
passlocker_tk/dialogs/new_totp_account_dialog.py,sha256=5lE6y6OFlJQ-3jA8xnFVUjxkjoWrdMrumFc2kKnv1Q0,4617
|
|
12
|
+
passlocker_tk/dialogs/password_dialog.py,sha256=1yyOs9685GkU424r23-AxvJH0X153xgXT69BT23YyEc,1424
|
|
13
|
+
passlocker_tk-1.0.0.dist-info/licenses/LICENSE,sha256=QpSZvCTowu2-PPT037J3-vehh3zhzqaLNuewDC_Pmq8,1180
|
|
14
|
+
tests/create_demo.py,sha256=1bPehKcO29PR0Okfv4u8X6MkzGsVI-NAmHcSd5b2Zmk,480
|
|
15
|
+
passlocker_tk-1.0.0.dist-info/METADATA,sha256=FreKef83PrLcFRY3dPCXPm582YNK5Xdt1565r6-RyUw,1064
|
|
16
|
+
passlocker_tk-1.0.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
17
|
+
passlocker_tk-1.0.0.dist-info/entry_points.txt,sha256=soxCksphAO0Lh5BszrgWCpImbxAia5971pjn3WAS_C0,62
|
|
18
|
+
passlocker_tk-1.0.0.dist-info/top_level.txt,sha256=C20iyX3dKT0W5juAHxbFC21sxoS46v08XDKYXxFUfTI,20
|
|
19
|
+
passlocker_tk-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Public License
|
|
2
|
+
|
|
3
|
+
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Public License ("Public License").
|
|
4
|
+
|
|
5
|
+
You may not use the material for commercial purposes.
|
|
6
|
+
|
|
7
|
+
You are free to:
|
|
8
|
+
- Share — copy and redistribute the material in any medium or format
|
|
9
|
+
- Adapt — remix, transform, and build upon the material
|
|
10
|
+
|
|
11
|
+
Under the following terms:
|
|
12
|
+
- Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made.
|
|
13
|
+
- NonCommercial — You may not use the material for commercial purposes.
|
|
14
|
+
- ShareAlike — If you remix, transform, or build upon the material, you must distribute your contributions under the same license as the original.
|
|
15
|
+
- No additional restrictions — You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits.
|
|
16
|
+
|
|
17
|
+
For the full license text, see: https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode
|
|
18
|
+
|
|
19
|
+
SPDX-License-Identifier: CC-BY-NC-SA-4.0
|
tests/create_demo.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from passlocker_tk import PassLockerGui
|
|
2
|
+
from passlocker.models.account import Account
|
|
3
|
+
from generator_emails import GeneratorEmails
|
|
4
|
+
|
|
5
|
+
app: PassLockerGui = PassLockerGui()
|
|
6
|
+
accounts: list[Account] = []
|
|
7
|
+
generator: GeneratorEmails = GeneratorEmails()
|
|
8
|
+
created_on = "2026-01-01"
|
|
9
|
+
for _ in range(10000):
|
|
10
|
+
accounts.append(
|
|
11
|
+
Account(
|
|
12
|
+
"MyService",
|
|
13
|
+
generator.generate_email(),
|
|
14
|
+
created_on
|
|
15
|
+
)
|
|
16
|
+
)
|
|
17
|
+
app.table.populate(accounts)
|
|
18
|
+
app.mainloop()
|