diff --git a/assets/icons/settings_icon.png b/assets/icons/settings_icon.png new file mode 100755 index 0000000..6d827bc Binary files /dev/null and b/assets/icons/settings_icon.png differ diff --git a/core/requests.py b/core/requests.py index b48ebe8..152a328 100644 --- a/core/requests.py +++ b/core/requests.py @@ -1,6 +1,56 @@ from urllib import request import json +_ORIGINAL_SOCKET = None + +def socks_monkey_patch(proxy_info: dict = None): + import socks + import socket + + if proxy_info["username"] and proxy_info["password"]: + socks.set_default_proxy( + socks.SOCKS5 if proxy_info["type"] == "SOCKS5" else socks.SOCKS4, + proxy_info["host"], + proxy_info["port"], + username=proxy_info["username"], + password=proxy_info["password"] + ) + else: + socks.set_default_proxy( + socks.SOCKS5 if proxy_info["type"] == "SOCKS5" else socks.SOCKS4, + proxy_info["host"], + proxy_info["port"], + ) + + _ORIGINAL_SOCKET = socket.socket # save our socket before patching monkey patching socks + socket.socket = socks.socksocket + + +def http_monkey_patch(proxy_info: dict = None): + if proxy_info and proxy_info["type"] == "HTTP": + proxy_str = f"{proxy_info['host']}:{proxy_info['port']}" + if proxy_info["username"] and proxy_info["password"]: + proxy_str = f"{proxy_info['username']}:{proxy_info['password']}@{proxy_str}" + + proxy_handler = request.ProxyHandler({ + 'http': 'http://' + proxy_str, + 'https': 'http://' + proxy_str + }) + + opener = request.build_opener(proxy_handler) + request.install_opener(opener) + + +def undo_monkey_patching(): + # This undos the custom opener for urllib + request.install_opener(request.build_opener()) + + # This tries to undo the monkey patching we did using Pysocks + if _ORIGINAL_SOCKET: + import socket + socket.socket = _ORIGINAL_SOCKET + + def http_request(url: str, method: str, auth_token: str = None, payload: dict = None, longpoll: int = -1) -> dict: if payload: payload = json.dumps(payload).encode() diff --git a/logic/authentication.py b/logic/authentication.py index 6d81207..7b8456a 100644 --- a/logic/authentication.py +++ b/logic/authentication.py @@ -16,7 +16,7 @@ def authenticate_account(user_data: dict) -> dict: if not 'challenge' in response: raise ValueError("Server did not give authenticatation challenge! Are you sure this is a Coldwire server ?") except Exception: - if 'proxy_info' in user_data: + if user_data["settings"]["proxy_info"] is not None: raise ValueError("Could not connect to server! Are you sure your proxy settings are valid ?") else: raise ValueError("Could not connect to server! Are you sure the URL is valid ?") diff --git a/logic/message.py b/logic/message.py index d7c2f2b..fc47c4a 100644 --- a/logic/message.py +++ b/logic/message.py @@ -46,7 +46,7 @@ def generate_and_send_pads(user_data, user_data_lock, contact_id: str, ui_queue) contact_kyber_public_key = user_data["contacts"][contact_id]["ephemeral_keys"]["contact_public_key"] our_lt_private_key = user_data["contacts"][contact_id]["lt_sign_keys"]["our_keys"]["private_key"] - + ciphertext_blob, pads = generate_kyber_shared_secrets(contact_kyber_public_key) diff --git a/logic/storage.py b/logic/storage.py index 856b536..a32fd45 100644 --- a/logic/storage.py +++ b/logic/storage.py @@ -16,7 +16,7 @@ def check_account_file() -> bool: def load_account_data(password = None) -> dict: user_data = None - if not password: + if password is None: with open(ACCOUNT_FILE_PATH, "r", encoding="utf-8") as f: user_data = json.load(f) else: @@ -35,7 +35,8 @@ def load_account_data(password = None) -> dict: user_data["tmp"] = { "ephemeral_key_send_lock": {}, - "pfs_do_not_inform": {} + "pfs_do_not_inform": {}, + "password": password } @@ -99,8 +100,8 @@ def save_account_data(user_data: dict, user_data_lock, password = None) -> None: with user_data_lock: user_data = copy.deepcopy(user_data) - if password == None and "password" in user_data: - password = user_data["password"] + if (password is None) and (user_data["tmp"]["password"] is not None): + password = user_data["tmp"]["password"] del user_data["tmp"] @@ -157,7 +158,7 @@ def save_account_data(user_data: dict, user_data_lock, password = None) -> None: - if not password: + if password is None: with open(ACCOUNT_FILE_PATH, "w", encoding="utf-8") as f: json.dump(user_data, f, indent=2) else: diff --git a/logic/user.py b/logic/user.py new file mode 100644 index 0000000..78c4255 --- /dev/null +++ b/logic/user.py @@ -0,0 +1,10 @@ +def build_initial_user_data() -> dict: + return { + "server_url": None, + "contacts": {}, + "tmp": {}, + "settings": { + "proxy_info": None, + "ignore_new_contacts_smp": False, + } + } diff --git a/ui/connect_window.py b/ui/connect_window.py index 428538d..691a0cf 100644 --- a/ui/connect_window.py +++ b/ui/connect_window.py @@ -2,6 +2,8 @@ from ui.password_window import PasswordWindow from logic.storage import save_account_data from logic.authentication import authenticate_account +from core.requests import socks_monkey_patch, http_monkey_patch, undo_monkey_patching +from logic.user import build_initial_user_data from core.crypto import generate_sign_keys from urllib.parse import urlparse import tkinter as tk @@ -20,7 +22,6 @@ def __init__(self, master): self.configure(bg="black") self.resizable(False, False) - # Server input self.label = tk.Label(self, text="Enter server URL:", fg="white", bg="black") self.label.pack(pady=(20, 5)) @@ -31,7 +32,6 @@ def __init__(self, master): enhanced_entry(self.server_url, placeholder="I.e. example.com ...") - # Use Proxy self.use_proxy_var = tk.IntVar() self.proxy_check = tk.Checkbutton( self, @@ -46,18 +46,14 @@ def __init__(self, master): ) self.proxy_check.pack(pady=(10, 0), anchor="center") - # Proxy Fields Frame (hidden initially) self.proxy_fields_frame = tk.Frame(self, bg="black") - # Proxy row (type + address) self.proxy_row = tk.Frame(self.proxy_fields_frame, bg="black") - self.proxy_type_var = tk.StringVar(value="SOCKS5") + self.proxy_type_var = tk.StringVar(value="HTTP") self.proxy_type_var.trace_add("write", self.update_auth_visibility) - self.proxy_type_menu = tk.OptionMenu( - self.proxy_row, self.proxy_type_var, "HTTP", "SOCKS4", "SOCKS5" - ) + self.proxy_type_menu = tk.OptionMenu(self.proxy_row, self.proxy_type_var, "HTTP", "SOCKS4", "SOCKS5") self.proxy_type_menu.config(bg="gray15", fg="white", highlightthickness=0, width=7) self.proxy_type_menu.pack(side="left", padx=(0, 5)) @@ -69,7 +65,6 @@ def __init__(self, master): self.proxy_row.pack(pady=5) - # Auth row (username + password) self.auth_frame = tk.Frame(self.proxy_fields_frame, bg="black") self.proxy_user_entry = tk.Entry( @@ -86,16 +81,15 @@ def __init__(self, master): self.proxy_fields_frame.pack_forget() - # Status + Connect self.status_label = tk.Label(self, text="", fg="red", bg="black") self.status_label.pack(pady=5) self.connect_button = tk.Button(self, text="Connect", command=self.on_connect, bg="gray25", fg="white") self.connect_button.pack(pady=10) - # Shrink to fit default + # Shrink to fit default, autosize. self.update_idletasks() - self.geometry("") # Autosize + self.geometry("") def toggle_proxy_fields(self): if self.use_proxy_var.get(): @@ -105,11 +99,11 @@ def toggle_proxy_fields(self): self.proxy_fields_frame.pack_forget() self.update_idletasks() - self.geometry("") # Resize window + self.geometry("") # Resize again def update_auth_visibility(self, *args): - proxy_type = self.proxy_type_var.get().lower() - if proxy_type in ["http", "socks5"]: + proxy_type = self.proxy_type_var.get() + if proxy_type in ["HTTP", "SOCKS5"]: self.auth_frame.pack(pady=5) else: self.auth_frame.pack_forget() @@ -119,11 +113,14 @@ def update_auth_visibility(self, *args): def password_callback(self, password): - # We save the password (if any) in the user data for ease of access to simplify development - self.user_data = {"server_url": self.server_url_fixed, "password": password, "contacts": {}, "tmp": {}, "proxy_info": None} + # We save the password (if any) in the user data tmp dict for ease of access across codebase (i.e. saving) + self.user_data = build_initial_user_data() + self.user_data["server_url"] = self.server_url_fixed + self.user_data["tmp"]["password"] = password + proxy_info = self.get_proxy_info() if proxy_info: - self.user_data["proxy_info": proxy_info] + self.user_data["settings"]["proxy_info"] = proxy_info private_key, public_key = generate_sign_keys() @@ -137,6 +134,22 @@ def password_callback(self, password): self.connect_to_server() def connect_to_server(self): + if self.user_data["settings"]["proxy_info"]: + if self.user_data["settings"]["proxy_info"]["type"] in ["SOCKS5", "SOCKS4"]: + try: + import socks + except ImportError: + logger.error("SOCKS proxy set and we could not find PySocks. WARNING before you install PySocks: PySocks is largely unmaintained. It's highly recommended you use proxychains instead") + self.status_label.config(text="You need to install PySocks to enable SOCKS proxy support!") + return + + socks_monkey_patch(self.user_data["settings"]["proxy_info"]) + else: + http_monkey_patch(self.user_data["settings"]["proxy_info"]) + else: + undo_monkey_patching() + + try: self.user_data = authenticate_account(self.user_data) except ValueError as e: @@ -145,31 +158,36 @@ def connect_to_server(self): save_account_data(self.user_data, self.master.user_data_lock) self.destroy() - self.master.ready_to_authenticate_callback(self.user_data["password"]) + self.master.ready_to_authenticate_callback(self.user_data["tmp"]["password"], already_authenticated = True) def get_proxy_info(self): proxy_info = None if self.use_proxy_var.get(): - proxy_type = self.proxy_type_var.get().lower() - proxy_addr = self.proxy_addr_entry.get().strip() + proxy_type = self.proxy_type_var.get() + proxy_addr = self.proxy_addr_entry.get().strip() + username = self.proxy_user_entry.get().strip() + password = self.proxy_pass_entry.get().strip() + if not proxy_addr or ':' not in proxy_addr: self.status_label.config(text="Invalid proxy address.") return host, port = proxy_addr.split(':', 1) + try: + port = int(port) + except ValueError: + self.status_label.config(text="Invalid proxy address port!") + return + proxy_info = { "type": proxy_type, "host": host, - "port": int(port) + "port": port, + "username": username, + "password": password } - username = self.proxy_user_entry.get().strip() - password = self.proxy_pass_entry.get().strip() - if username and password: - proxy_info["username"] = username - proxy_info["password"] = password - if proxy_info: logger.info("Using proxy: %s", json.dumps(proxy_info, indent=2)) return proxy_info @@ -207,4 +225,4 @@ def on_connect(self, event=None): PasswordWindow(self, self.password_callback) else: self.status_label.config(text="") - self.password_callback(self.user_data["password"]) + self.password_callback(self.user_data["tmp"]["password"]) diff --git a/ui/contact_list.py b/ui/contact_list.py index 6f55f81..706cd0c 100644 --- a/ui/contact_list.py +++ b/ui/contact_list.py @@ -7,10 +7,12 @@ from ui.smp_setup_window import SMPSetupWindow from ui.smp_question_window import SMPQuestionWindow from ui.contact_nickname_prompt import ContactNicknamePrompt +from ui.settings_window import SettingsWindow from logic.authentication import authenticate_account from logic.storage import check_account_file, save_account_data, load_account_data from logic.background_worker import background_worker from logic.utils import thread_failsafe_wrapper +from core.requests import socks_monkey_patch, http_monkey_patch import tkinter as tk import sys import os @@ -53,13 +55,20 @@ def __init__(self): if call_the_callback: self.ready_to_authenticate_callback(None) - def ready_to_authenticate_callback(self, password): + def ready_to_authenticate_callback(self, password, already_authenticated: bool = False): self.user_data = load_account_data(password) - try: - self.user_data = authenticate_account(self.user_data) - except ValueError as e: - messagebox.showerror("Error", e) - sys.exit(1) + if already_authenticated is False: + if self.user_data["settings"]["proxy_info"]: + if self.user_data["settings"]["proxy_info"]["type"] in ["SOCKS5", "SOCKS4"]: + socks_monkey_patch(self.user_data["settings"]["proxy_info"]) + else: + http_monkey_patch(self.user_data["settings"]["proxy_info"]) + + try: + self.user_data = authenticate_account(self.user_data) + except ValueError as e: + messagebox.showerror("Error", e) + sys.exit(1) self.messages_store_tmp = {} @@ -154,6 +163,7 @@ def show_contacts(self): user_frame = tk.Frame(self, bg="black") user_frame.pack(pady=(10, 0)) + tk.Label( user_frame, text="Your ID: ", @@ -173,6 +183,22 @@ def show_contacts(self): username_label.pack(side="left") username_label.bind("", lambda e: self.copy_to_clipboard(username, "Your User ID has been copied to clipboard.")) + settings_icon = PhotoImage(file=os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "assets", "icons", "settings_icon.png"))) + settings_button = tk.Button( + user_frame, + image=settings_icon, + command = lambda: SettingsWindow(self), + bg="black", + relief="flat", + bd=0, + highlightthickness=0, + activebackground="black" + ) + settings_button.image = settings_icon # Prevents garbage collection + settings_button.pack(side="left", padx=(0, 0)) + + + header_frame = tk.Frame(self, bg="black") header_frame.pack(pady=10) @@ -189,7 +215,7 @@ def show_contacts(self): add_button = tk.Button( header_frame, image=plus_icon, - command=self.open_add_contact_prompt, + command = lambda: AddContactPrompt(self), bg="black", relief="flat", bd=0, @@ -232,10 +258,6 @@ def show_contacts(self): # We initialize the background worker thread and other hooks here to prevent race conditions self.init_hooks_and_background_worker() - def on_mousewheel(event): - canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") - - def new_contact(self, contact_id): with self.user_data_lock: contact_name = contact_id if not self.user_data["contacts"][contact_id]["nickname"] else self.user_data["contacts"][contact_id]["nickname"] @@ -334,7 +356,5 @@ def open_chat(self, contact_id): SMPSetupWindow(self, contact_id) - def open_add_contact_prompt(self): - AddContactPrompt(self) diff --git a/ui/settings_window.py b/ui/settings_window.py new file mode 100644 index 0000000..d317576 --- /dev/null +++ b/ui/settings_window.py @@ -0,0 +1,165 @@ +import tkinter as tk +import copy +import logging +from logic.storage import save_account_data +from tkinter import messagebox +from ui.utils import ( + ToolTip, + fake_readonly, + enhanced_entry +) + +logger = logging.getLogger(__name__) + +class SettingsWindow(tk.Toplevel): + def __init__(self, master): + super().__init__(master) + self.title("Settings") + self.configure(bg="black") + self.geometry("450x300") + self.resizable(False, False) + + with self.master.user_data_lock: + self.user_data_copied = copy.deepcopy(self.master.user_data) + + tk.Label( + self, + text="Settings", + fg="white", + bg="black", + font=("Helvetica", 14, "bold") + ).pack(pady=10) + + self.ignore_new_contacts_var = tk.BooleanVar(value = self.user_data_copied["settings"]["ignore_new_contacts_smp"]) + ignore_new_contacts_checkbox = tk.Checkbutton( + self, + text="Ignore unknown new verification requests", + variable=self.ignore_new_contacts_var, + fg="white", + bg="black", + selectcolor="black", + activebackground="black", + activeforeground="white" + ) + ignore_new_contacts_checkbox.pack(anchor="w", padx=20, pady=5) + ToolTip(ignore_new_contacts_checkbox, "Ignores SMP verification requests from people not already saved in your contacts") + + + + server_frame = tk.Frame(self, bg="black") + server_frame.pack(fill="x", padx=20, pady=5) + + tk.Label(server_frame, text="Server URL:", fg="white", bg="black").pack(side="left", padx=(0, 5)) + + self.server_entry = tk.Entry(server_frame, bg="gray15", fg="white", insertbackground="white", highlightthickness=0) + self.server_entry.insert(0, self.user_data_copied["server_url"]) + self.server_entry.bind("", fake_readonly) + self.server_entry.pack(fill="x", padx=5, pady=5) + + + proxy_frame = tk.Frame(self, bg="black") + proxy_frame.pack(fill="x", padx=20, pady=5) + + tk.Label(proxy_frame, text="Proxy Type:", fg="white", bg="black").pack(side="left", padx=(0, 5)) + + proxy_info = self.user_data_copied["settings"]["proxy_info"] + proxy_type = "None" + proxy_address = "" + proxy_username = "" + proxy_password = "" + + if proxy_info: + proxy_type = proxy_info["type"] + proxy_address = f"{proxy_info['host']}:{proxy_info['port']}" + proxy_username = proxy_info["username"] + proxy_password = proxy_info["password"] + + + self.proxy_type_var = tk.StringVar(value = proxy_type) + proxy_menu = tk.OptionMenu(proxy_frame, self.proxy_type_var, "None", "SOCKS5", "SOCKS4", "HTTP") + proxy_menu.config(bg="gray15", fg="white", activebackground="gray25", activeforeground="white", highlightthickness=0) + proxy_menu["menu"].config(bg="gray15", fg="white", activebackground="gray25", activeforeground="white") + proxy_menu.pack(side="left", padx=(0, 5)) + + self.proxy_address_entry = tk.Entry(proxy_frame, bg="gray15", fg="white", insertbackground="white", highlightthickness=0) + self.proxy_address_entry.insert(0, proxy_address) + self.proxy_address_entry.pack(side="left", fill="x", expand=True) + + proxy_cred_frame = tk.Frame(self, bg="black") + proxy_cred_frame.pack(fill="x", padx=20, pady=(2, 10)) + + self.proxy_username_entry = tk.Entry(proxy_cred_frame, bg="gray15", fg="white", insertbackground="white", highlightthickness=0) + self.proxy_username_entry.insert(0, proxy_username) + self.proxy_username_entry.pack(side="left", fill="x", expand=True, padx=(0, 5)) + + self.proxy_password_entry = tk.Entry(proxy_cred_frame,bg="gray15", fg="white", insertbackground="white", highlightthickness=0, show="*") + self.proxy_password_entry.insert(0, proxy_password) + self.proxy_password_entry.pack(side="left", fill="x", expand=True) + + enhanced_entry(self.proxy_address_entry, placeholder="Proxy server address") + enhanced_entry(self.proxy_username_entry, placeholder="Proxy username (optional)") + enhanced_entry(self.proxy_password_entry, placeholder="Proxy password (optional)", show="*") + + tk.Button(self, text="Save Settings", + font=("Helvetica", 12), + bg="gray20", fg="white", + activebackground="gray30", + activeforeground="white", + command=self.save_settings + ).pack(side="left", padx=(100, 0), pady=10) + + tk.Button(self, text="Cancel", + font=("Helvetica", 12), + bg="gray20", + fg="white", + activebackground="gray30", + activeforeground="white", + command=self.destroy + ).pack(side="right", padx=(0, 100), pady=10) + + self.transient(master) + self.grab_set() + + def save_settings(self): + proxy_type = self.proxy_type_var.get() + proxy_address = self.proxy_address_entry.get().strip() + + if proxy_type != "None": + if proxy_type in ["SOCKS5", "SOCKS4"]: + try: + import socks + except ImportError: + logger.error("SOCKS proxy set and we could not find PySocks. WARNING before you install PySocks: PySocks is largely unmaintained. It's highly recommended you use proxychains instead") + messagebox.showerror("Error", "You need to install PySocks to enable SOCKS proxy support!") + return + + if not proxy_address or ':' not in proxy_address: + messagebox.showerror("Error", "You did not enter a valid proxy address!") + return + + proxy_username = self.proxy_username_entry.get().strip() + proxy_password = self.proxy_password_entry.get().strip() + + host, port = proxy_address.split(':', 1) + + try: + port = int(port) + except ValueError: + messagebox.showerror("Error", "Invalid proxy address port!") + return + + with self.master.user_data_lock: + self.master.user_data["settings"]["proxy_info"] = { + "type": proxy_type, + "host": host, + "port": port, + "username": proxy_username, + "password": proxy_password + } + + with self.master.user_data_lock: + self.master.user_data["settings"]["ignore_new_contacts_smp"] = self.ignore_new_contacts_var.get() + + save_account_data(self.master.user_data, self.master.user_data_lock) + messagebox.showinfo("Settings", "Settings saved!") + self.destroy() diff --git a/ui/utils.py b/ui/utils.py index 0ecacc5..5f666a2 100644 --- a/ui/utils.py +++ b/ui/utils.py @@ -1,32 +1,50 @@ +import tkinter as tk + def enhanced_entry(entry, placeholder=None, show=""): entry.bind("", select_all) - if placeholder: - add_placeholder(entry, placeholder, show=show) + if placeholder and not entry.get(): + add_placeholder(entry, placeholder, show=show) return entry - def add_placeholder(entry, placeholder, color="gray50", show=""): - def on_focus_in(event): - if entry.get() == placeholder: + normal_fg = entry.cget("fg") # Remember original color + placeholder_state = {"active": True} # Track if placeholder is active + + def set_placeholder(): + entry.delete(0, "end") + entry.insert(0, placeholder) + entry.config(fg=color, show="") + placeholder_state["active"] = True + + def clear_placeholder(): + if placeholder_state["active"]: entry.delete(0, "end") - entry.config(fg="white", show="") - else: - entry.config(show=show) + entry.config(fg=normal_fg, show=show) + placeholder_state["active"] = False - def on_focus_out(event): - if entry.get() == "": - entry.insert(0, placeholder) - entry.config(fg=color, show="") - elif entry.get() != placeholder: - entry.config(show=show) + def on_focus_in(event): + if placeholder_state["active"]: + clear_placeholder() + def on_focus_out(event): + if not entry.get(): + set_placeholder() def on_key(event): - entry.config(show=show) + if placeholder_state["active"]: + clear_placeholder() + + original_get = entry.get + def safe_get(): + if placeholder_state["active"]: + return "" + return original_get() + + entry.get = safe_get + + set_placeholder() - entry.insert(0, placeholder) - entry.config(fg=color) entry.bind("", on_focus_in) entry.bind("", on_focus_out) entry.bind("", on_key) @@ -36,4 +54,48 @@ def select_all(event=None): event.widget.icursor('end') return "break" +def fake_readonly(event): + # Allow Ctrl+C (copy) and Ctrl+A (select all) + # tk. doesn't support customizing Read-only entries, this hacks around it while still allowing copying + if (event.state & 0x4 and event.keysym.lower() in ("c", "a")): + return + return "break" # Block everything else + + +class ToolTip: + def __init__(self, widget, text): + self.widget = widget + self.text = text + self.tip_window = None + widget.bind("", self.show_tip) + widget.bind("", self.hide_tip) + + def show_tip(self, event=None): + if self.tip_window or not self.text: + return + x, y, _, _ = self.widget.bbox("insert") or (0, 0, 0, 0) + x += self.widget.winfo_rootx() + 25 + y += self.widget.winfo_rooty() + 20 + + self.tip_window = tw = tk.Toplevel(self.widget) + tw.wm_overrideredirect(True) + tw.wm_geometry(f"+{x}+{y}") + + label = tk.Label( + tw, + text=self.text, + justify="left", + background="black", + foreground="white", + relief="solid", + borderwidth=1, + font=("Helvetica", 9) + ) + label.pack(ipadx=5, ipady=2) + + def hide_tip(self, event=None): + tw = self.tip_window + self.tip_window = None + if tw: + tw.destroy()