v1.2.0: - GUI localization (EN/RU/ZH) with language switcher and persistent selection - About dialog (ⓘ) with app info, features, quick start guide - core/i18n.py — internationalization module with t() function - All GUI components translated via t() keys v1.3.0: - TOTP/2FA tab — Google Authenticator compatible codes with live 30s countdown, one-click copy, per-server secret management - core/totp.py — TOTP module (pyotp, RFC 6238) - core/logger.py — rotating file logger (5MB, 3 backups) - Stronger Fernet encryption key with automatic migration from old key - Thread-safe server store with locks, atomic writes, auto-restore on corruption - Parallel status checks via ThreadPoolExecutor (up to 10 concurrent) - SSH client: explicit channel cleanup, Unix key permissions - Server dialog: port validation (1-65535), TOTP secret field - Language change preserves active tab and server selection - pyotp dependency added Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
286 lines
10 KiB
Python
286 lines
10 KiB
Python
"""
|
|
TOTP tab — Google Authenticator compatible 2FA codes.
|
|
Live countdown, one-click copy, per-server secrets.
|
|
"""
|
|
|
|
import threading
|
|
import customtkinter as ctk
|
|
from core.i18n import t
|
|
|
|
|
|
class TOTPTab(ctk.CTkFrame):
|
|
def __init__(self, master, store):
|
|
super().__init__(master, fg_color="transparent")
|
|
self.store = store
|
|
self._current_alias: str | None = None
|
|
self._timer_id = None
|
|
|
|
# Title
|
|
self.title_label = ctk.CTkLabel(
|
|
self, text=t("totp_title"),
|
|
font=ctk.CTkFont(size=16, weight="bold"), anchor="w"
|
|
)
|
|
self.title_label.pack(fill="x", padx=15, pady=(15, 5))
|
|
|
|
# Description
|
|
self.desc_label = ctk.CTkLabel(
|
|
self, text=t("totp_desc"),
|
|
anchor="w", text_color="#9ca3af", wraplength=600, justify="left"
|
|
)
|
|
self.desc_label.pack(fill="x", padx=15, pady=(0, 10))
|
|
|
|
# Server name
|
|
self.server_label = ctk.CTkLabel(
|
|
self, text=t("no_server_selected"),
|
|
font=ctk.CTkFont(size=13), anchor="w", text_color="#6b7280"
|
|
)
|
|
self.server_label.pack(fill="x", padx=15, pady=(0, 10))
|
|
|
|
# Code display frame
|
|
code_frame = ctk.CTkFrame(self, fg_color="#1e1e2e", corner_radius=12)
|
|
code_frame.pack(fill="x", padx=15, pady=(0, 10))
|
|
|
|
self.code_label = ctk.CTkLabel(
|
|
code_frame, text="------",
|
|
font=ctk.CTkFont(family="Consolas", size=42, weight="bold"),
|
|
text_color="#22c55e"
|
|
)
|
|
self.code_label.pack(pady=(20, 5))
|
|
|
|
self.timer_label = ctk.CTkLabel(
|
|
code_frame, text="",
|
|
font=ctk.CTkFont(size=12), text_color="#9ca3af"
|
|
)
|
|
self.timer_label.pack(pady=(0, 5))
|
|
|
|
self.progress_bar = ctk.CTkProgressBar(code_frame, width=300, height=6)
|
|
self.progress_bar.pack(pady=(0, 15))
|
|
self.progress_bar.set(1.0)
|
|
|
|
# Copy button
|
|
self.copy_btn = ctk.CTkButton(
|
|
self, text=t("totp_copy"), width=200, height=40,
|
|
font=ctk.CTkFont(size=14),
|
|
fg_color="#22c55e", hover_color="#16a34a",
|
|
command=self._copy_code
|
|
)
|
|
self.copy_btn.pack(pady=(5, 15))
|
|
|
|
# Secret management section
|
|
secret_frame = ctk.CTkFrame(self, fg_color="transparent")
|
|
secret_frame.pack(fill="x", padx=15, pady=(10, 5))
|
|
|
|
ctk.CTkLabel(
|
|
secret_frame, text=t("totp_secret_label"),
|
|
font=ctk.CTkFont(size=13, weight="bold"), anchor="w"
|
|
).pack(fill="x")
|
|
|
|
entry_row = ctk.CTkFrame(secret_frame, fg_color="transparent")
|
|
entry_row.pack(fill="x", pady=(5, 0))
|
|
|
|
self.secret_entry = ctk.CTkEntry(
|
|
entry_row, show="*",
|
|
placeholder_text=t("totp_secret_placeholder"),
|
|
font=ctk.CTkFont(family="Consolas", size=12)
|
|
)
|
|
self.secret_entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
|
|
|
|
self.show_secret_btn = ctk.CTkButton(
|
|
entry_row, text=t("show"), width=70,
|
|
fg_color="#6b7280", hover_color="#4b5563",
|
|
command=self._toggle_secret
|
|
)
|
|
self.show_secret_btn.pack(side="left", padx=(0, 5))
|
|
self._secret_visible = False
|
|
|
|
self.save_secret_btn = ctk.CTkButton(
|
|
entry_row, text=t("totp_save_secret"), width=100,
|
|
command=self._save_secret
|
|
)
|
|
self.save_secret_btn.pack(side="left", padx=(0, 5))
|
|
|
|
self.remove_secret_btn = ctk.CTkButton(
|
|
entry_row, text=t("totp_remove_secret"), width=100,
|
|
fg_color="#ef4444", hover_color="#dc2626",
|
|
command=self._remove_secret
|
|
)
|
|
self.remove_secret_btn.pack(side="left")
|
|
|
|
# Generate random secret button
|
|
self.gen_secret_btn = ctk.CTkButton(
|
|
secret_frame, text=t("totp_generate_secret"), width=180,
|
|
fg_color="#6b7280", hover_color="#4b5563",
|
|
command=self._generate_secret
|
|
)
|
|
self.gen_secret_btn.pack(anchor="w", pady=(8, 0))
|
|
|
|
# Status log
|
|
self.status_label = ctk.CTkLabel(
|
|
self, text="", anchor="w", text_color="#9ca3af"
|
|
)
|
|
self.status_label.pack(fill="x", padx=15, pady=(5, 10))
|
|
|
|
def set_server(self, alias: str | None):
|
|
self._current_alias = alias
|
|
self._stop_timer()
|
|
|
|
if not alias:
|
|
self.server_label.configure(text=t("no_server_selected"))
|
|
self.code_label.configure(text="------")
|
|
self.timer_label.configure(text="")
|
|
self.progress_bar.set(1.0)
|
|
self.secret_entry.delete(0, "end")
|
|
self.status_label.configure(text="")
|
|
return
|
|
|
|
self.server_label.configure(text=f"🖥 {alias}")
|
|
server = self.store.get_server(alias)
|
|
if not server:
|
|
return
|
|
|
|
secret = server.get("totp_secret", "")
|
|
self.secret_entry.delete(0, "end")
|
|
if secret:
|
|
self.secret_entry.insert(0, secret)
|
|
self._start_timer()
|
|
else:
|
|
self.code_label.configure(text="------")
|
|
self.timer_label.configure(text=t("totp_no_secret"))
|
|
self.progress_bar.set(1.0)
|
|
|
|
def _start_timer(self):
|
|
self._stop_timer()
|
|
self._update_code()
|
|
|
|
def _stop_timer(self):
|
|
if self._timer_id:
|
|
self.after_cancel(self._timer_id)
|
|
self._timer_id = None
|
|
|
|
def _update_code(self):
|
|
if not self._current_alias:
|
|
return
|
|
|
|
server = self.store.get_server(self._current_alias)
|
|
if not server:
|
|
return
|
|
|
|
secret = server.get("totp_secret", "")
|
|
if not secret:
|
|
self.code_label.configure(text="------")
|
|
self.timer_label.configure(text=t("totp_no_secret"))
|
|
self.progress_bar.set(1.0)
|
|
return
|
|
|
|
try:
|
|
from core.totp import get_code_with_timer
|
|
data = get_code_with_timer(secret)
|
|
code = data["code"]
|
|
remaining = data["remaining"]
|
|
progress = data["progress"]
|
|
|
|
# Format code with space in middle: "123 456"
|
|
formatted = f"{code[:3]} {code[3:]}"
|
|
self.code_label.configure(text=formatted)
|
|
self.timer_label.configure(text=t("totp_remaining").format(sec=remaining))
|
|
self.progress_bar.set(progress)
|
|
|
|
# Color based on time remaining
|
|
if remaining <= 5:
|
|
self.code_label.configure(text_color="#ef4444")
|
|
elif remaining <= 10:
|
|
self.code_label.configure(text_color="#f59e0b")
|
|
else:
|
|
self.code_label.configure(text_color="#22c55e")
|
|
|
|
except Exception as e:
|
|
self.code_label.configure(text="ERROR")
|
|
self.timer_label.configure(text=str(e))
|
|
|
|
# Schedule next update in 1 second
|
|
self._timer_id = self.after(1000, self._update_code)
|
|
|
|
def _copy_code(self):
|
|
code_text = self.code_label.cget("text").replace(" ", "")
|
|
if code_text and code_text != "------" and code_text != "ERROR":
|
|
self.clipboard_clear()
|
|
self.clipboard_append(code_text)
|
|
self.status_label.configure(text=t("totp_copied"), text_color="#22c55e")
|
|
self.after(2000, lambda: self.status_label.configure(text=""))
|
|
else:
|
|
self.status_label.configure(text=t("totp_no_code"), text_color="#ef4444")
|
|
self.after(2000, lambda: self.status_label.configure(text=""))
|
|
|
|
def _toggle_secret(self):
|
|
self._secret_visible = not self._secret_visible
|
|
self.secret_entry.configure(show="" if self._secret_visible else "*")
|
|
self.show_secret_btn.configure(text=t("hide") if self._secret_visible else t("show"))
|
|
|
|
def _save_secret(self):
|
|
if not self._current_alias:
|
|
self.status_label.configure(text=t("no_server_selected"), text_color="#ef4444")
|
|
return
|
|
|
|
secret = self.secret_entry.get().strip()
|
|
if not secret:
|
|
self.status_label.configure(text=t("totp_secret_empty"), text_color="#ef4444")
|
|
self.after(2000, lambda: self.status_label.configure(text=""))
|
|
return
|
|
|
|
# Validate secret
|
|
try:
|
|
from core.totp import get_code
|
|
get_code(secret)
|
|
except Exception:
|
|
self.status_label.configure(text=t("totp_secret_invalid"), text_color="#ef4444")
|
|
self.after(2000, lambda: self.status_label.configure(text=""))
|
|
return
|
|
|
|
server = self.store.get_server(self._current_alias)
|
|
if server:
|
|
server["totp_secret"] = secret
|
|
self.store.update_server(self._current_alias, server)
|
|
self.status_label.configure(text=t("totp_secret_saved"), text_color="#22c55e")
|
|
self.after(2000, lambda: self.status_label.configure(text=""))
|
|
self._start_timer()
|
|
|
|
def _remove_secret(self):
|
|
if not self._current_alias:
|
|
return
|
|
|
|
server = self.store.get_server(self._current_alias)
|
|
if server and "totp_secret" in server:
|
|
del server["totp_secret"]
|
|
self.store.update_server(self._current_alias, server)
|
|
self.secret_entry.delete(0, "end")
|
|
self._stop_timer()
|
|
self.code_label.configure(text="------", text_color="#22c55e")
|
|
self.timer_label.configure(text="")
|
|
self.progress_bar.set(1.0)
|
|
self.status_label.configure(text=t("totp_secret_removed"), text_color="#9ca3af")
|
|
self.after(2000, lambda: self.status_label.configure(text=""))
|
|
|
|
def _generate_secret(self):
|
|
try:
|
|
from core.totp import generate_secret
|
|
secret = generate_secret()
|
|
self.secret_entry.delete(0, "end")
|
|
self.secret_entry.insert(0, secret)
|
|
self.status_label.configure(text=t("totp_secret_generated"), text_color="#22c55e")
|
|
self.after(2000, lambda: self.status_label.configure(text=""))
|
|
except Exception as e:
|
|
self.status_label.configure(text=f"Error: {e}", text_color="#ef4444")
|
|
|
|
def update_language(self):
|
|
self.title_label.configure(text=t("totp_title"))
|
|
self.desc_label.configure(text=t("totp_desc"))
|
|
self.copy_btn.configure(text=t("totp_copy"))
|
|
self.save_secret_btn.configure(text=t("totp_save_secret"))
|
|
self.remove_secret_btn.configure(text=t("totp_remove_secret"))
|
|
self.gen_secret_btn.configure(text=t("totp_generate_secret"))
|
|
self.show_secret_btn.configure(
|
|
text=t("hide") if self._secret_visible else t("show")
|
|
)
|
|
if not self._current_alias:
|
|
self.server_label.configure(text=t("no_server_selected"))
|