py2fa-gtk/main.py
2025-10-24 08:13:25 +03:00

594 lines
24 KiB
Python
Executable file

#!/usr/bin/env python3
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, Gdk, GLib, Pango
import base64
import hashlib
import pyotp
import json
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
import os
import re
import time
class PyTFAApp:
def __init__(self):
# Create main window
self.window = Gtk.Window(title="PyTFA - 2FA Authenticator")
self.window.set_default_size(500, 500)
self.window.set_border_width(10)
self.window.connect("destroy", Gtk.main_quit)
# Create main box
self.main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
self.window.add(self.main_box)
# Create header bar
header = Gtk.HeaderBar()
header.set_show_close_button(True)
header.props.title = "PyTFA - 2FA Authenticator"
self.window.set_titlebar(header)
# Add account button
self.add_button = Gtk.Button.new_with_label("Add Account")
self.add_button.connect("clicked", self.on_add_account)
header.pack_end(self.add_button)
# Timer label
self.timer_label = Gtk.Label()
header.pack_start(self.timer_label)
# Global timer progress bar anchored in header
self.timer_progress = Gtk.ProgressBar()
self.timer_progress.set_hexpand(True)
header.pack_start(self.timer_progress)
# Create scrolled window for accounts
scrolled = Gtk.ScrolledWindow()
self.main_box.pack_start(scrolled, True, True, 0)
# Create list box for accounts
self.accounts_list = Gtk.ListBox()
self.accounts_list.set_selection_mode(Gtk.SelectionMode.NONE)
scrolled.add(self.accounts_list)
# Password entry for encryption
self.password = None
self.account_widgets = {} # Store mapping of account to widgets
self.expanded_service = None
# Load encrypted data
self.load_encrypted_data()
# Start timer to update codes
GLib.timeout_add_seconds(1, self.update_codes)
def derive_key(self, password, salt):
"""Derive encryption key from password"""
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=100000,
)
return base64.urlsafe_b64encode(kdf.derive(password.encode()))
def encrypt_account(self, account, password):
"""Encrypt a single account"""
salt = os.urandom(16)
key = self.derive_key(password, salt)
f = Fernet(key)
encrypted = f.encrypt(json.dumps(account).encode())
return base64.b64encode(salt + encrypted).decode()
def decrypt_account(self, encrypted_data, password):
"""Decrypt a single account"""
try:
data = base64.b64decode(encrypted_data.encode())
salt, encrypted = data[:16], data[16:]
key = self.derive_key(password, salt)
f = Fernet(key)
decrypted = f.decrypt(encrypted)
return json.loads(decrypted.decode())
except:
return None
def load_encrypted_data(self):
"""Load encrypted data from the script itself"""
# Read the current script
with open(__file__, 'r') as f:
content = f.read()
# Find the encrypted data block
match = re.search(r'# ENCRYPTED_DATA_BEGIN\n(.*?)\n# ENCRYPTED_DATA_END', content, re.DOTALL)
if match:
encrypted_block = match.group(1).strip()
# Ask for password
self.show_password_dialog(encrypted_block)
else:
# No encrypted data found, initialize empty
self.accounts = []
self.show_password_dialog(initial_setup=True)
def save_encrypted_data(self):
"""Save encrypted data to the script itself"""
if not self.password or not self.accounts:
return
# Encrypt each account separately
encrypted_accounts = []
for account in self.accounts:
encrypted = self.encrypt_account(account, self.password)
encrypted_accounts.append(f"# {encrypted}")
encrypted_block = '# ENCRYPTED_DATA_BEGIN\n' + '\n'.join(encrypted_accounts) + '\n# ENCRYPTED_DATA_END'
# Read the current script
with open(__file__, 'r') as f:
content = f.read()
# Replace or add the encrypted data block
pattern = r'# ENCRYPTED_DATA_BEGIN\n.*\n# ENCRYPTED_DATA_END'
if re.search(pattern, content, re.DOTALL):
new_content = re.sub(pattern, encrypted_block, content, flags=re.DOTALL)
else:
# Add the encrypted data block at the end
new_content = content + '\n' + encrypted_block
# Write back to the script
with open(__file__, 'w') as f:
f.write(new_content)
def show_password_dialog(self, encrypted_block=None, initial_setup=False):
"""Show password entry dialog"""
dialog = Gtk.Dialog(
title="PyTFA Password" if not initial_setup else "Set PyTFA Password",
parent=self.window,
flags=0
)
dialog.add_buttons(
Gtk.STOCK_OK, Gtk.ResponseType.OK,
Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL
)
dialog.set_default_size(300, 150)
box = dialog.get_content_area()
box.set_spacing(10)
box.set_margin_top(10)
box.set_margin_bottom(10)
box.set_margin_start(10)
box.set_margin_end(10)
label = Gtk.Label(label="Enter password to decrypt your 2FA accounts:" if not initial_setup
else "Set a password to encrypt your 2FA accounts:")
box.add(label)
password_entry = Gtk.Entry()
password_entry.set_visibility(False)
password_entry.set_placeholder_text("Password")
box.add(password_entry)
# For initial setup, add a confirmation field
if initial_setup:
confirm_entry = Gtk.Entry()
confirm_entry.set_visibility(False)
confirm_entry.set_placeholder_text("Confirm Password")
box.add(confirm_entry)
else:
confirm_entry = None
def trigger_ok(*_args):
dialog.response(Gtk.ResponseType.OK)
password_entry.connect("activate", trigger_ok)
if initial_setup and confirm_entry:
confirm_entry.connect("activate", trigger_ok)
dialog.show_all()
response = dialog.run()
if response == Gtk.ResponseType.OK:
password = password_entry.get_text()
if initial_setup:
confirm = confirm_entry.get_text() if confirm_entry else ""
if password != confirm:
error_dialog = Gtk.MessageDialog(
parent=self.window,
flags=0,
message_type=Gtk.MessageType.ERROR,
buttons=Gtk.ButtonsType.OK,
text="Passwords do not match!"
)
error_dialog.run()
error_dialog.destroy()
dialog.destroy()
self.show_password_dialog(initial_setup=True)
return
self.password = password
self.accounts = []
else:
self.password = password
# Try to decrypt the accounts
self.accounts = []
for line in encrypted_block.split('\n'):
line = line.strip()
if line.startswith('# '):
encrypted_data = line[2:].strip()
account = self.decrypt_account(encrypted_data, password)
if account:
self.accounts.append(account)
if not self.accounts:
error_dialog = Gtk.MessageDialog(
parent=self.window,
flags=0,
message_type=Gtk.MessageType.ERROR,
buttons=Gtk.ButtonsType.OK,
text="Incorrect password or no accounts found!"
)
error_dialog.run()
error_dialog.destroy()
dialog.destroy()
self.show_password_dialog(encrypted_block)
return
self.update_accounts_list()
else:
if not initial_setup:
Gtk.main_quit()
else:
# Can't proceed without a password
error_dialog = Gtk.MessageDialog(
parent=self.window,
flags=0,
message_type=Gtk.MessageType.ERROR,
buttons=Gtk.ButtonsType.OK,
text="Password is required!"
)
error_dialog.run()
error_dialog.destroy()
dialog.destroy()
self.show_password_dialog(initial_setup=True)
return
dialog.destroy()
def on_add_account(self, widget):
"""Handle add account button click"""
dialog = Gtk.Dialog(
title="Add 2FA Account",
parent=self.window,
flags=0
)
dialog.add_buttons(
Gtk.STOCK_OK, Gtk.ResponseType.OK,
Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL
)
dialog.set_default_size(400, 200)
box = dialog.get_content_area()
box.set_spacing(10)
box.set_margin_top(10)
box.set_margin_bottom(10)
box.set_margin_start(10)
box.set_margin_end(10)
# Service name entry
service_label = Gtk.Label(label="Service Name:")
service_label.set_halign(Gtk.Align.START)
box.add(service_label)
service_entry = Gtk.Entry()
service_entry.set_placeholder_text("e.g., GitHub, Google")
box.add(service_entry)
# Secret key entry
secret_label = Gtk.Label(label="Secret Key:")
secret_label.set_halign(Gtk.Align.START)
box.add(secret_label)
secret_entry = Gtk.Entry()
secret_entry.set_placeholder_text("Base32 secret key")
box.add(secret_entry)
dialog.show_all()
response = dialog.run()
if response == Gtk.ResponseType.OK:
service = service_entry.get_text().strip()
secret = secret_entry.get_text().strip().replace(" ", "")
if service and secret:
# Check for duplicates
duplicate_service = any(acc['service'] == service for acc in self.accounts)
duplicate_secret = any(acc['secret'] == secret for acc in self.accounts)
if duplicate_service:
self.show_error_dialog("Service name already exists!")
elif duplicate_secret:
self.show_error_dialog("Secret already exists for another account!")
else:
# Add the account
account = {
'service': service,
'secret': secret
}
self.accounts.append(account)
self.update_accounts_list()
self.save_encrypted_data()
dialog.destroy()
def show_error_dialog(self, message):
"""Show an error dialog"""
dialog = Gtk.MessageDialog(
parent=self.window,
flags=0,
message_type=Gtk.MessageType.ERROR,
buttons=Gtk.ButtonsType.OK,
text=message
)
dialog.run()
dialog.destroy()
def update_accounts_list(self):
"""Update the accounts list in the UI"""
# Clear current list and widget mapping
for child in self.accounts_list.get_children():
self.accounts_list.remove(child)
self.account_widgets = {}
# Add accounts to list
for account in self.accounts:
row = Gtk.ListBoxRow()
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
box.set_margin_top(10)
box.set_margin_bottom(10)
box.set_margin_start(10)
box.set_margin_end(10)
row.add(box)
# Header with service name and arrow indicator
header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
arrow_image = Gtk.Image.new_from_icon_name("go-next-symbolic", Gtk.IconSize.BUTTON)
header_box.pack_start(arrow_image, False, False, 0)
service_label = Gtk.Label(label=account['service'])
service_label.set_halign(Gtk.Align.START)
service_label.set_hexpand(True)
header_box.pack_start(service_label, True, True, 0)
header_event_box = Gtk.EventBox()
header_event_box.set_visible_window(False)
header_event_box.add(header_box)
box.pack_start(header_event_box, False, False, 0)
# Details revealer containing buttons and code
details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
buttons_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
view_btn = Gtk.Button.new_from_icon_name("document-properties", Gtk.IconSize.BUTTON)
view_btn.set_tooltip_text("View Secret")
view_btn.connect("clicked", self.on_view_secret, account)
buttons_box.pack_start(view_btn, False, False, 0)
rename_btn = Gtk.Button.new_from_icon_name("edit", Gtk.IconSize.BUTTON)
rename_btn.set_tooltip_text("Rename Account")
rename_btn.connect("clicked", self.on_rename_account, account)
buttons_box.pack_start(rename_btn, False, False, 0)
delete_btn = Gtk.Button.new_from_icon_name("edit-delete", Gtk.IconSize.BUTTON)
delete_btn.set_tooltip_text("Delete Account")
delete_btn.connect("clicked", self.on_delete_account, account)
buttons_box.pack_start(delete_btn, False, False, 0)
details_box.pack_start(buttons_box, False, False, 0)
code_label = Gtk.Label(label="Generating...")
code_label.set_halign(Gtk.Align.START)
code_label.set_selectable(True)
font = Pango.FontDescription("Monospace 16")
code_label.override_font(font)
details_box.pack_start(code_label, False, False, 0)
revealer = Gtk.Revealer()
revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_DOWN)
revealer.add(details_box)
box.pack_start(revealer, False, False, 0)
header_event_box.connect(
"button-press-event",
lambda _widget, _event, service=account['service']: self.toggle_account_row(service)
)
self.accounts_list.add(row)
self.account_widgets[account['service']] = {
'code_label': code_label,
'revealer': revealer,
'arrow_image': arrow_image
}
self.accounts_list.show_all()
if self.expanded_service and self.expanded_service not in self.account_widgets:
self.expanded_service = None
for service, widgets in self.account_widgets.items():
is_expanded = service == self.expanded_service
widgets['revealer'].set_reveal_child(is_expanded)
widgets['arrow_image'].set_from_icon_name(
"go-down-symbolic" if is_expanded else "go-next-symbolic",
Gtk.IconSize.BUTTON
)
def toggle_account_row(self, service):
"""Expand or collapse the account row for the given service."""
if service == self.expanded_service:
widgets = self.account_widgets.get(service)
if widgets:
widgets['revealer'].set_reveal_child(False)
widgets['arrow_image'].set_from_icon_name("go-next-symbolic", Gtk.IconSize.BUTTON)
self.expanded_service = None
return
if self.expanded_service and self.expanded_service in self.account_widgets:
prev_widgets = self.account_widgets[self.expanded_service]
prev_widgets['revealer'].set_reveal_child(False)
prev_widgets['arrow_image'].set_from_icon_name("go-next-symbolic", Gtk.IconSize.BUTTON)
widgets = self.account_widgets.get(service)
if widgets:
widgets['revealer'].set_reveal_child(True)
widgets['arrow_image'].set_from_icon_name("go-down-symbolic", Gtk.IconSize.BUTTON)
self.expanded_service = service
def on_view_secret(self, widget, account):
"""Handle view secret button click"""
dialog = Gtk.Dialog(
title=f"Secret for {account['service']}",
parent=self.window,
flags=0
)
dialog.add_buttons(Gtk.STOCK_OK, Gtk.ResponseType.OK)
dialog.set_default_size(400, 100)
box = dialog.get_content_area()
box.set_spacing(10)
box.set_margin_top(10)
box.set_margin_bottom(10)
box.set_margin_start(10)
box.set_margin_end(10)
# Secret label
secret_label = Gtk.Label(label=account['secret'])
secret_label.set_selectable(True)
# Use monospace font for secret
font = Pango.FontDescription("Monospace 12")
secret_label.override_font(font)
box.add(secret_label)
dialog.show_all()
dialog.run()
dialog.destroy()
def on_rename_account(self, widget, account):
"""Handle rename account button click"""
dialog = Gtk.Dialog(
title=f"Rename {account['service']}",
parent=self.window,
flags=0
)
dialog.add_buttons(
Gtk.STOCK_OK, Gtk.ResponseType.OK,
Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL
)
dialog.set_default_size(400, 100)
box = dialog.get_content_area()
box.set_spacing(10)
box.set_margin_top(10)
box.set_margin_bottom(10)
box.set_margin_start(10)
box.set_margin_end(10)
# Service name entry
service_label = Gtk.Label(label="New Service Name:")
service_label.set_halign(Gtk.Align.START)
box.add(service_label)
service_entry = Gtk.Entry()
service_entry.set_text(account['service'])
box.add(service_entry)
dialog.show_all()
response = dialog.run()
if response == Gtk.ResponseType.OK:
new_service = service_entry.get_text().strip()
if new_service and new_service != account['service']:
# Check for duplicates
duplicate = any(acc['service'] == new_service for acc in self.accounts if acc != account)
if duplicate:
self.show_error_dialog("Service name already exists!")
else:
# Update the account
account['service'] = new_service
self.update_accounts_list()
self.save_encrypted_data()
dialog.destroy()
def on_delete_account(self, widget, account):
"""Handle account deletion"""
dialog = Gtk.MessageDialog(
parent=self.window,
flags=0,
message_type=Gtk.MessageType.QUESTION,
buttons=Gtk.ButtonsType.YES_NO,
text=f"Delete account for {account['service']}?"
)
response = dialog.run()
dialog.destroy()
if response == Gtk.ResponseType.YES:
self.accounts = [acc for acc in self.accounts if acc != account]
self.update_accounts_list()
self.save_encrypted_data()
def update_codes(self):
"""Update all TOTP codes and timer"""
current_time = time.time()
time_remaining = 30 - (current_time % 30)
# Update global timer
self.timer_label.set_label(f"Time remaining: {int(time_remaining)}s")
self.timer_progress.set_fraction(time_remaining / 30.0)
for account in self.accounts:
try:
totp = pyotp.TOTP(account['secret'])
code = totp.now()
# Update the code label if it exists
if account['service'] in self.account_widgets:
self.account_widgets[account['service']]['code_label'].set_label(code)
except Exception as e:
print(f"Error generating code for {account['service']}: {e}")
if account['service'] in self.account_widgets:
self.account_widgets[account['service']]['code_label'].set_label("Invalid")
return True # Continue timeout
def run(self):
"""Run the application"""
self.window.show_all()
Gtk.main()
# Create and run the application
if __name__ == "__main__":
app = PyTFAApp()
app.run()
# ENCRYPTED_DATA_BEGIN
# Ewlit3P96YVgZ86uVZrX7GdBQUFBQUJvLXdxUkJHS0NJNDN3MUVqaEl0dl9WV3JwSjVxbEVpc2J0RVVvLXh0MEJWRVM5OU5URXVfd1ZzekhTOEtOZ1VlUFNuRnVoeWJhNG1TLWcySmhRWjN0TjAteHR4QlpkR0M5Q0Z5X2hNRG0zUzRtS3Q2NWQwZjBiSmszNEg1S3lJNVBKOC1GS3drc1hyaFU1aVV4MldvQVA5M1RhUHZwbS1ZaTJvdmxTQ1JTNEpoamlMND0=
# Qcd7zQyGRMZ1SYi/F8FwT2dBQUFBQUJvLXdxUjdOSnpSZEJfZFoxOEpPN2k4VERPYWcxc0xWc1FvTDZmSlBrcTJicVplRF9lX2dDSTkxaEI4SjVQUUhwbjM3SnlKTy1GVk1tSUI0dkFFUjVWenBXZEZJYzRXanNhUVNaSUtTaTZCUV9QTzlZV3BSLUZ1cjJxQzJBa0UxMmJPUDZVRUNwSGFyOVRlY245VjJTNkRLSzdZUEVvSEE0MlhiUlBtVE9sTkxnMEpMek5lVnU2NDQwX0lMV0NEdTkxUEI2TnFtWmduVmQ1TUpRRHZmay1QQllza1E9PQ==
# G7ULlxeW09qxA8SlSBKK1GdBQUFBQUJvLXdxUm5JNkFZVWVnclZqeVBPalp5QlFiNmluYkF6RUJfdGtGak12Qk5oVkZIOVZubmJybmN5VXcwLUd2ZVVsUmk1Snp1ZmQ5RXRPTEgxakVqazFaS0pKcHBDNklVRjk4QW43cExKU1JKejNhOUJkaWQzYWhrQVBQZVp0NFpJTjVnanlEYU1SdXZ4dTVoQ3pRZUtjTXhjdlRJcWJfMldpSFUzMU92UU5Lc2ZzMHJqY21aSkljanZaa0d0bnNuNnJQZ0R1d3FmeHREYnlTVTdKa3RRRlRkOFBGMmc9PQ==
# to+Bv630zSX8GD21uLYxImdBQUFBQUJvLXdxUndoRUItNlNnLW1DeEZiLWNRQWN0ME5xTFdiM1cwenBRNGplc1hyOS1UU1dRQWkzU1ozSTlZaTFQalhGWmhIZE05cjJYSFo1R2g0SkV4U0lMRFRFNkYwVGwwWGtiX1J6bUdZWkkxWjNiRzFHNExEWDVaQ1hGQ0VHZzR5akU3R3JQczJjTlRNdy1aYTFWNkF6ZGhfcm1ZSzZrazhjSjg4a256dzJ1aVJxMlNsa0ExMURKUFo3S2NGYnBiYk5RSE9VMWk5cF93Q1AyQ0stSGpEc0EzaDEteHc9PQ==
# +9TvLgxAJ5yWkfHKg28LsmdBQUFBQUJvLXdxUll4VFN0N2ZCR2FMY2E3T2QxMVJyUFd2OHhQMkZ1RGNVVEFTRFhxT0o3djVOb3dzdjctaVVhZTNQQmQ1N3hmc05hZ3Q4YXY1ZHp5Ny1QNmphVGgxUmRYTWFycTd1TEc0a0tVT19FMnJfZHE5S3RyeWNWUnB6SVFfTUc3aWwzV0NFMGhnemhtX2k3OWFoMEktZ1haSlZaS3lLaktzbU1qN1hSbXVWbV9SN3R0amxOR0VPekFHSzVfVkc3ckVrcFZiVTBwcVVraFhEQnRoQnZOUU44NG8tamc9PQ==
# 98ekR6qtjEkNyVWP7USeCWdBQUFBQUJvLXdxUmdFam5zYVBhTjNLSFljUDAtcm1BOGtoU041anlKUlRHZmg3Uk1jTTNBdGlFaThQWVEydFBZTFNnRmExU1BQejNUYzBRWThRQjQ0ZHRrWXV0R25mbFRsdnM5NlNiamJ0WWZfT3l3MkM0YTlZVGpGLUhlX2hXejZQQWZZMG4wbG5pdEpDbjVWSFdnZGQwM0NhWXlZWmlldz09
# DuOwoPOc8aBVRxPRnYH7vWdBQUFBQUJvLXdxUkRfY0NMS3VNTmVzUGF2cG9La01WY3g4eGVGM3ZpQ0ZSS1lyOW9oaVJiUFBSY3dHY1cwQ2l6TEFwcllUNnNCM2pQRFI5TDlUM2EtUkhXZEhidGFZdlVzUzdLTVByWDZ4ZmQ2YjA1VFVzTWtMcXJ0N0FGU3p4ZGFoTm14amM1eTVBUnhoSGI3ZzJZVGJ3cFVGN3BWbDR3MTJabVEtX0l1b3h2cUZyX3dVMXM5aVNXUldyMHgtU2VQamdWUDNrZUE5Wkk3NVIwTlIxbW8yTnNoYkY4YlJ4TXc9PQ==
# GUAmOT2XETLoscmqGUYwdWdBQUFBQUJvLXdxUmphVXQ2V1ppS2w5c1JHTDNDYlkxSG9qdGJ1WWNnbXA3UDcwc3BaMExIRU81WTFwUkcxOG0zTjFFNlFBbzQzc2R2V1d1X0wtNUVoVklSTWxVRUF3Vk5CSDZmd3NZUXZSWXNETHlDSDhGcmtXVlp1Q1lWaTJPZlIyOGx4VXZMUXZOb25IeEJXeWtlc1V2RS1mUTBzTFNlYWNob18wTGFYei1pcmQtVEhfM1FaVDNXbXpSalZGb3JXZUpPVU5FcUl0Zm5mcUdRQ2hoWmNfdkczM1JNcjhiV09MOG9SbWU0cU9WZzVCNUxGSDhQak09
# ENCRYPTED_DATA_END