ghostbytes 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.
ghostbytes/gui/gui.py ADDED
@@ -0,0 +1,2809 @@
1
+ """Ghostbytes desktop application interface."""
2
+
3
+ import base64
4
+ import ctypes
5
+ import os
6
+ import sys
7
+ import textwrap
8
+ import threading
9
+ import webbrowser
10
+ from tkinter import PhotoImage, filedialog
11
+
12
+ import customtkinter as ctk
13
+ from CTkMessagebox import CTkMessagebox
14
+ from ctkfontawesome import icon_to_ctkimage
15
+ from PIL import Image
16
+ from ghostbytes import __version__, __license__, __link__
17
+ from ghostbytes.crypto.config import (
18
+ AVAIL_ALG,
19
+ AVAIL_HASH,
20
+ AVAIL_HASH_STR,
21
+ AVAIL_RANDOM_STR,
22
+ ENCRYPTED_SUFFIX,
23
+ RSA_KEY_OUT_FORMAT,
24
+ OVERWRITE_OPTIONS,
25
+ COMMON_RSA_SIZE,
26
+ RSA_SIZE_WARNING_THRESHOLD,
27
+ CryptoConfig,
28
+ )
29
+ from ghostbytes.error import GeneralError, invalid_argument
30
+ from ghostbytes.gui import wrappers as wr
31
+ from ghostbytes.gui.theme import (
32
+ ABOUT_BOX_CONTENT,
33
+ ACCENT,
34
+ ACCENT_BORDER,
35
+ ACCENT_HOVER,
36
+ ACCENT_ON,
37
+ ACCENT_TINT,
38
+ ACTIONS,
39
+ CARD_HEIGHT,
40
+ CARD_WIDTH,
41
+ CAPABILITIES,
42
+ DANGER,
43
+ ICON_ICO,
44
+ ICON_PNG,
45
+ MONO_FONT,
46
+ SIDEBAR_LABELS,
47
+ SUCCESS,
48
+ TABS,
49
+ THEME,
50
+ WARNING,
51
+ )
52
+
53
+ def _set_windows_app_id():
54
+ if os.name == "nt":
55
+ ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
56
+ "Ghostbytes.Ghostbytes")
57
+
58
+
59
+ class App(ctk.CTk):
60
+ """Main Ghostbytes application window."""
61
+
62
+ def __init__(self):
63
+ _set_windows_app_id()
64
+ super().__init__()
65
+
66
+ self.title("Ghostbytes - An file encryption utility")
67
+ self._set_appearance_mode("system")
68
+ try:
69
+ if os.name == "nt":
70
+ self.iconbitmap(default=str(ICON_ICO))
71
+ else:
72
+ self._taskbar_icon = PhotoImage(file=str(ICON_PNG))
73
+ self.iconphoto(True, self._taskbar_icon)
74
+ except Exception:
75
+ pass
76
+ self.geometry("1200x750")
77
+ self.propagate(False)
78
+ self.grid_rowconfigure(0, weight=1)
79
+ self.grid_columnconfigure(1, weight=1)
80
+
81
+ # Session-scoped "don't warn me again" flag for the large-RSA-key
82
+ # warning (generation & use of keys above RSA_SIZE_WARNING_THRESHOLD).
83
+ self._suppress_rsa_warning = False
84
+
85
+ self.sidebar_buttons = []
86
+ self._nav_icons = {}
87
+ self.content = ctk.CTkScrollableFrame(
88
+ self,
89
+ corner_radius=0,
90
+ fg_color=THEME["content_bg"]
91
+ )
92
+ self.content.grid(row=0, column=1, sticky="nsew")
93
+ self.content.grid_columnconfigure(0, weight=1)
94
+
95
+ self.tab = TABS["HEADER"][0]
96
+ self._build_sidebar()
97
+ self._build_context()
98
+
99
+ # ------------------------------------------------------------------ #
100
+ # Sidebar / navigation
101
+ # ------------------------------------------------------------------ #
102
+
103
+ def _build_sidebar(self):
104
+ sidebar = ctk.CTkFrame(self, width=210, fg_color=THEME["sidebar_bg"])
105
+ sidebar.propagate(False)
106
+ sidebar.grid(row=0, column=0, sticky="nsw")
107
+
108
+ brand = ctk.CTkFrame(sidebar, fg_color="transparent")
109
+ brand.grid(row=0, column=0, sticky="ew", padx=18, pady=(22, 16))
110
+ ctk.CTkLabel(
111
+ brand,
112
+ text="Ghostbytes",
113
+ text_color=THEME["content_text"],
114
+ font=self._font(
115
+ 16,
116
+ "bold")).pack(
117
+ side="left",
118
+ padx=(
119
+ 8,
120
+ 0))
121
+
122
+ row_counter = 1
123
+ for group, tabs in TABS.items():
124
+ if group not in ("HEADER", "FOOTER"):
125
+ label = ctk.CTkLabel(
126
+ sidebar, text_color=THEME["gray_text"], font=self._font(
127
+ 11, weight="bold"), text=SIDEBAR_LABELS.get(
128
+ group, group.title()), anchor="w")
129
+ label.grid(
130
+ row=row_counter,
131
+ column=0,
132
+ sticky="ew",
133
+ padx=18,
134
+ pady=(
135
+ 14,
136
+ 3))
137
+ row_counter += 1
138
+ for tab in tabs:
139
+ active_icon = icon_to_ctkimage(
140
+ tab[0], fill=ACCENT, scale_to_width=15)
141
+ inactive_icon = icon_to_ctkimage(
142
+ tab[0], fill=THEME["text_fg"], scale_to_width=15)
143
+ is_active = tab == self.tab
144
+ tab_button = ctk.CTkButton(
145
+ sidebar,
146
+ anchor="w",
147
+ corner_radius=8,
148
+ text_color=ACCENT if is_active else THEME["text_fg"],
149
+ fg_color=ACCENT_TINT if is_active else "transparent",
150
+ hover_color=THEME["box_color"],
151
+ font=self._font(size=13),
152
+ text=tab[1],
153
+ image=active_icon if is_active else inactive_icon,
154
+ command=lambda tab=tab: self._change_tabs(tab)
155
+ )
156
+ tab_button.grid(
157
+ row=row_counter,
158
+ column=0,
159
+ sticky="ew",
160
+ padx=12,
161
+ pady=2 if group != "FOOTER" else (30, 8)
162
+ )
163
+ self._nav_icons[tab_button] = (active_icon, inactive_icon)
164
+ self.sidebar_buttons.append(tab_button)
165
+ row_counter += 1
166
+ sidebar.grid_rowconfigure(row_counter, weight=2)
167
+
168
+ def _change_tabs(self, new_tab):
169
+ self.tab = new_tab
170
+ for btn in self.sidebar_buttons:
171
+ active_icon, inactive_icon = self._nav_icons[btn]
172
+ if btn._text == new_tab[1]:
173
+ btn.configure(
174
+ fg_color=ACCENT_TINT,
175
+ text_color=ACCENT,
176
+ image=active_icon)
177
+ else:
178
+ btn.configure(
179
+ fg_color="transparent",
180
+ text_color=THEME["text_fg"],
181
+ image=inactive_icon)
182
+
183
+ self._build_context()
184
+
185
+ def _find_tab(self, label):
186
+ for tabs in TABS.values():
187
+ for tab in tabs:
188
+ if tab[1] == label:
189
+ return tab
190
+ return None
191
+
192
+ def _build_context(self):
193
+ switch = {
194
+ "Home": self._build_home,
195
+ "Encrypt / Decrypt": self._build_crypto,
196
+ "Generate Key Pair": self._build_genkey,
197
+ "Verify Key Pair": self._build_verifykey,
198
+ "Key Information": self._build_keyinfo,
199
+ "Hash File(s) (Checksum)": self._build_hashfile,
200
+ "Random": self._build_random,
201
+ "Password Generator": self._build_pwdgen,
202
+ "Benchmark": self._build_benchmark,
203
+ "Secure Delete": self._build_sdelete,
204
+ "Wipe Free Space": self._build_wipe_space,
205
+ "About": self._build_about
206
+ }
207
+ if self.tab[1] not in switch:
208
+ self._error_win(
209
+ "GUI Internal Error",
210
+ f"Cannot find build function for tab `{
211
+ self.tab[1]}`",
212
+ True)
213
+
214
+ for widget in self.content.winfo_children():
215
+ widget.destroy()
216
+ switch[self.tab[1]]()
217
+
218
+ # ------------------------------------------------------------------ #
219
+ # Home
220
+ # ------------------------------------------------------------------ #
221
+
222
+ def _build_home(self):
223
+ app_details = ctk.CTkFrame(
224
+ self.content,
225
+ corner_radius=0,
226
+ fg_color="transparent")
227
+ app_details.grid(row=0, column=0, sticky="new", padx=32, pady=(28, 18))
228
+ app_details.grid_columnconfigure(1, weight=1)
229
+ app_details.grid_columnconfigure(2, weight=0, minsize=250)
230
+
231
+ try:
232
+ icon = ctk.CTkImage(
233
+ Image.open(ICON_PNG),
234
+ Image.open(ICON_PNG),
235
+ (64, 64)
236
+ )
237
+ ctk.CTkLabel(
238
+ app_details,
239
+ image=icon,
240
+ text="",
241
+ width=64).grid(
242
+ row=0,
243
+ column=0,
244
+ sticky="nw")
245
+ except Exception:
246
+ pass
247
+
248
+ text_frame = ctk.CTkFrame(app_details, fg_color="transparent")
249
+ text_frame.grid(row=0, column=1, sticky="new", padx=(15, 0))
250
+
251
+ ctk.CTkLabel(
252
+ text_frame, text="Ghostbytes", text_color=THEME["content_text"],
253
+ font=self._font(24, "bold"), anchor="w"
254
+ ).pack(anchor="w")
255
+ ctk.CTkLabel(
256
+ text_frame, text="File Encryption Utility", text_color=ACCENT,
257
+ font=self._font(13, "bold"), anchor="w"
258
+ ).pack(anchor="w")
259
+ ctk.CTkLabel(
260
+ text_frame,
261
+ text="Military-grade cryptographic toolkit for file encryption, key "
262
+ "management, and secure data handling. Built for privacy.",
263
+ text_color=THEME["slight_gray"],
264
+ font=self._font(12),
265
+ anchor="w",
266
+ justify="left",
267
+ wraplength=500).pack(
268
+ anchor="w",
269
+ pady=(
270
+ 4,
271
+ 0))
272
+
273
+ about = ctk.CTkFrame(
274
+ app_details,
275
+ fg_color=THEME["box_color"],
276
+ border_width=1,
277
+ border_color=THEME["box_border"],
278
+ corner_radius=THEME["corner_rad"])
279
+ about.grid(row=0, column=2, sticky="ne", padx=(24, 0))
280
+ about.grid_columnconfigure(1, weight=1)
281
+ ctk.CTkLabel(
282
+ about,
283
+ text="About Ghostbytes",
284
+ text_color=THEME["content_text"],
285
+ font=self._font(
286
+ 13,
287
+ "bold"),
288
+ anchor="w").grid(
289
+ row=0,
290
+ column=0,
291
+ columnspan=2,
292
+ sticky="w",
293
+ padx=14,
294
+ pady=(
295
+ 10,
296
+ 4))
297
+ for index, (label, value) in enumerate(
298
+ (("Version", __version__), ("License", __license__), ("Source", __link__))):
299
+ ctk.CTkLabel(
300
+ about,
301
+ text=label,
302
+ text_color=THEME["slight_gray"],
303
+ font=self._font(10),
304
+ anchor="w").grid(
305
+ row=index + 1,
306
+ column=0,
307
+ sticky="w",
308
+ padx=14,
309
+ pady=2)
310
+ value_label = ctk.CTkLabel(
311
+ about,
312
+ text=value,
313
+ text_color=ACCENT if label == "Source" else THEME["content_text"],
314
+ font=self._mono_font(10),
315
+ anchor="w",
316
+ justify="left",
317
+ wraplength=185,
318
+ cursor="hand2" if label == "Source" else None)
319
+ value_label.grid(
320
+ row=index + 1,
321
+ column=1,
322
+ sticky="w",
323
+ padx=(
324
+ 8,
325
+ 14),
326
+ pady=2)
327
+ if label == "Source":
328
+ value_label.bind(
329
+ "<Button-1>",
330
+ lambda _event,
331
+ url=value: webbrowser.open_new_tab(url))
332
+ ctk.CTkLabel(
333
+ about,
334
+ text="Privacy is not a feature. It's a foundation",
335
+ text_color=THEME["slight_gray"],
336
+ font=self._font(
337
+ 12,
338
+ "bold"),
339
+ anchor="w").grid(
340
+ row=4,
341
+ column=0,
342
+ columnspan=2,
343
+ sticky="w",
344
+ padx=14,
345
+ pady=(
346
+ 8,
347
+ 10))
348
+
349
+ divider = ctk.CTkFrame(
350
+ self.content,
351
+ height=1,
352
+ fg_color=THEME["box_border"])
353
+ divider.grid(row=1, column=0, sticky="ew", padx=32)
354
+
355
+ quick_actions = ctk.CTkFrame(self.content, fg_color="transparent")
356
+ quick_actions.grid(
357
+ row=2,
358
+ column=0,
359
+ sticky="new",
360
+ padx=32,
361
+ pady=(
362
+ 20,
363
+ 20))
364
+
365
+ ctk.CTkLabel(
366
+ quick_actions,
367
+ text="Quick Actions",
368
+ text_color=THEME["content_text"],
369
+ font=self._font(
370
+ 15,
371
+ "bold"),
372
+ anchor="w").grid(
373
+ row=0,
374
+ column=0,
375
+ sticky="w",
376
+ pady=(
377
+ 0,
378
+ 5))
379
+
380
+ cards = [
381
+ self._build_action_card(quick_actions, icon_name, title, desc, target)
382
+ for icon_name, title, desc, target in ACTIONS
383
+ ]
384
+
385
+ quick_actions.bind(
386
+ "<Configure>",
387
+ lambda e: self._home_organise(
388
+ quick_actions,
389
+ cards))
390
+ self.after(50, lambda: self._home_organise(quick_actions, cards))
391
+
392
+ def _build_action_card(self, parent, icon_name, title, desc, target):
393
+ card = ctk.CTkFrame(
394
+ parent,
395
+ width=CARD_WIDTH,
396
+ height=CARD_HEIGHT,
397
+ corner_radius=THEME["corner_rad"],
398
+ fg_color=THEME["box_color"],
399
+ border_color=THEME["box_border"],
400
+ border_width=THEME["box_border_width"],
401
+ cursor="hand2")
402
+ card.grid_propagate(False)
403
+ card.grid_columnconfigure(1, weight=1)
404
+
405
+ badge = ctk.CTkFrame(
406
+ card,
407
+ width=32,
408
+ height=32,
409
+ corner_radius=8,
410
+ fg_color=ACCENT_TINT)
411
+ badge.grid(row=0, column=0, rowspan=2, sticky="nw", padx=12, pady=12)
412
+ badge.grid_propagate(False)
413
+ ctk.CTkLabel(
414
+ badge,
415
+ text="",
416
+ image=icon_to_ctkimage(
417
+ icon_name,
418
+ fill=ACCENT,
419
+ scale_to_width=14)).place(
420
+ relx=0.5,
421
+ rely=0.5,
422
+ anchor="center")
423
+
424
+ ctk.CTkLabel(
425
+ card, text=title, text_color=THEME["content_text"],
426
+ font=self._font(13, "bold"), anchor="w"
427
+ ).grid(row=0, column=1, sticky="sw", padx=(0, 10), pady=(14, 0))
428
+
429
+ ctk.CTkLabel(
430
+ card,
431
+ text=desc,
432
+ text_color=THEME["slight_gray"],
433
+ font=self._font(11),
434
+ anchor="nw",
435
+ justify="left",
436
+ wraplength=CARD_WIDTH -
437
+ 60).grid(
438
+ row=1,
439
+ column=1,
440
+ sticky="nw",
441
+ padx=(
442
+ 0,
443
+ 10),
444
+ pady=(
445
+ 2,
446
+ 10))
447
+
448
+ def go(_event=None):
449
+ tab = self._find_tab(target)
450
+ if tab:
451
+ self._change_tabs(tab)
452
+
453
+ self._bind_recursive(card, go)
454
+ card.bind("<Enter>", lambda e: card.configure(border_color=ACCENT))
455
+ card.bind(
456
+ "<Leave>", lambda e: card.configure(
457
+ border_color=THEME["box_border"]))
458
+
459
+ return card
460
+
461
+ def _bind_recursive(self, widget, command):
462
+ widget.bind("<Button-1>", command)
463
+ for child in widget.winfo_children():
464
+ self._bind_recursive(child, command)
465
+
466
+ def _home_organise(self, quick_actions, cards):
467
+ """Re-grid action cards into as many columns as fit the current frame width."""
468
+ if not quick_actions.winfo_exists():
469
+ return # the user navigated away before this deferred call fired
470
+
471
+ frame_width = quick_actions.winfo_width()
472
+ if frame_width < 10:
473
+ return
474
+
475
+ cols = max(1, frame_width // (CARD_WIDTH + 10))
476
+
477
+ for idx, card in enumerate(cards):
478
+ row, col = divmod(idx, cols)
479
+ card.grid(row=row + 1, column=col, sticky="nw", padx=5, pady=5)
480
+
481
+ num_rows = -(-len(cards) // cols)
482
+ heading_h = 30
483
+ new_height = heading_h + num_rows * (CARD_HEIGHT + 10) + 10
484
+ quick_actions.configure(height=new_height)
485
+
486
+ # ------------------------------------------------------------------ #
487
+ # Encrypt / Decrypt
488
+ # ------------------------------------------------------------------ #
489
+
490
+ def _build_crypto(self):
491
+ self._page_header(
492
+ "lock",
493
+ "Encrypt / Decrypt",
494
+ "Secure or restore one or more files.")
495
+ page = self._page(row=1)
496
+
497
+ row = 0
498
+ self._section(page, row, "Mode")
499
+ row += 1
500
+ mode_var = ctk.StringVar(value="Encrypt")
501
+ ctk.CTkSegmentedButton(
502
+ page,
503
+ values=[
504
+ "Encrypt",
505
+ "Decrypt"],
506
+ variable=mode_var,
507
+ fg_color=THEME["box_color"],
508
+ selected_color=ACCENT,
509
+ selected_hover_color=ACCENT_HOVER,
510
+ unselected_color=THEME["box_color"],
511
+ text_color=THEME["content_text"],
512
+ height=38,
513
+ font=self._font(
514
+ 13,
515
+ "bold"),
516
+ command=lambda v: refresh()).grid(
517
+ row=row,
518
+ column=0,
519
+ columnspan=2,
520
+ sticky="ew")
521
+ row += 1
522
+
523
+ self._section(page, row, "Files")
524
+ row += 1
525
+ self._field_label(page, row, "Input files (comma and space separated)")
526
+ row += 1
527
+ in_entry = self._entry(page, row, "Choose one or more files")
528
+ selected_inputs = []
529
+ self._file_row(
530
+ page,
531
+ row,
532
+ in_entry,
533
+ multiple=True,
534
+ selected=selected_inputs,
535
+ on_browse=lambda: auto_output())
536
+ row += 1
537
+
538
+ self._field_label(page, row, "Output file")
539
+ row += 1
540
+
541
+ def auto_output(*_):
542
+ src = selected_inputs[0] if selected_inputs else in_entry.get().split(",")[
543
+ 0].strip()
544
+ if not src:
545
+ return
546
+ out_entry.delete(0, "end")
547
+ if mode_var.get() == "Encrypt":
548
+ out_entry.insert(0, src + ENCRYPTED_SUFFIX)
549
+ else:
550
+ out_entry.insert(0, wr.remove_encrypted_suffix(src))
551
+
552
+ out_entry = self._entry(page, row, "Where to save the result")
553
+ self._file_row(page, row, out_entry, save=True)
554
+ row += 1
555
+
556
+ in_entry.bind("<KeyRelease>", auto_output)
557
+
558
+ self._section(page, row, "Protection")
559
+ row += 1
560
+ key_type_var = ctk.StringVar(value="AES (password)")
561
+ ctk.CTkSegmentedButton(
562
+ page,
563
+ values=[
564
+ "AES (password)",
565
+ "ML-KEM (Post Quantum, pub-key auth)",
566
+ "RSA (pub-key auth)"],
567
+ variable=key_type_var,
568
+ fg_color=THEME["box_color"],
569
+ selected_color=ACCENT,
570
+ selected_hover_color=ACCENT_HOVER,
571
+ unselected_color=THEME["box_color"],
572
+ text_color=THEME["content_text"],
573
+ height=38,
574
+ font=self._font(
575
+ 13,
576
+ "bold"),
577
+ command=lambda _value: sync_protection_algorithm()).grid(
578
+ row=row,
579
+ column=0,
580
+ columnspan=2,
581
+ sticky="ew",
582
+ pady=(
583
+ 0,
584
+ 6))
585
+ row += 1
586
+ key_row = row
587
+ row += 1
588
+ constructing_config = CryptoConfig()
589
+
590
+ pwd_frame = ctk.CTkFrame(page, fg_color="transparent")
591
+ pwd_frame.grid_columnconfigure(0, weight=1)
592
+ pwd_entry = ctk.CTkEntry(
593
+ pwd_frame, placeholder_text="Password", show="•",
594
+ fg_color=THEME["box_color"], border_color=THEME["box_border"],
595
+ text_color=THEME["content_text"]
596
+ )
597
+ pwd_entry.grid(row=0, column=0, sticky="ew", pady=(0, 6))
598
+ confirm_entry = ctk.CTkEntry(
599
+ pwd_frame, placeholder_text="Confirm password", show="•",
600
+ fg_color=THEME["box_color"], border_color=THEME["box_border"],
601
+ text_color=THEME["content_text"]
602
+ )
603
+ confirm_entry.grid(row=1, column=0, sticky="ew", pady=(0, 6))
604
+
605
+ rsa_frame = ctk.CTkFrame(page, fg_color="transparent")
606
+ rsa_frame.grid_columnconfigure(0, weight=1)
607
+ key_entry = ctk.CTkEntry(
608
+ rsa_frame, placeholder_text="RSA key file (.pem)",
609
+ fg_color=THEME["box_color"], border_color=THEME["box_border"],
610
+ text_color=THEME["content_text"]
611
+ )
612
+ key_entry.grid(row=0, column=0, sticky="ew", pady=(0, 6))
613
+ self._file_row(rsa_frame, 0, key_entry)
614
+ pass_entry = ctk.CTkEntry(
615
+ rsa_frame, placeholder_text="Key passphrase (if any)", show="•",
616
+ fg_color=THEME["box_color"], border_color=THEME["box_border"],
617
+ text_color=THEME["content_text"]
618
+ )
619
+ pass_entry.grid(
620
+ row=1,
621
+ column=0,
622
+ columnspan=2,
623
+ sticky="ew",
624
+ pady=(
625
+ 0,
626
+ 6))
627
+ rsa_mode_var, _menu = self._option_grid(
628
+ rsa_frame, 2, [
629
+ "Hybrid (any file size)", "Direct RSA-OAEP (small files only)"], label=None)
630
+
631
+ def selected_algorithm():
632
+ if key_type_var.get().startswith("AES"):
633
+ return "aes"
634
+ if key_type_var.get().startswith("RSA"):
635
+ return "extended_oaep" if rsa_mode_var.get().startswith("Hybrid") else "rsa-oaep"
636
+ if constructing_config.algorithm not in (
637
+ "ML-KEM-768", "ML-KEM-1024"):
638
+ constructing_config.algorithm = "ML-KEM-768"
639
+ return constructing_config.algorithm
640
+
641
+ def sync_protection_algorithm(*_):
642
+ constructing_config.algorithm = selected_algorithm()
643
+ refresh()
644
+
645
+ rsa_mode_var.trace_add("write", sync_protection_algorithm)
646
+
647
+ mlkem_frame = ctk.CTkFrame(
648
+ page,
649
+ fg_color=ACCENT_TINT,
650
+ border_width=1,
651
+ border_color=ACCENT_BORDER)
652
+ mlkem_frame.grid_columnconfigure(0, weight=1)
653
+ mlkem_key_entry = ctk.CTkEntry(
654
+ mlkem_frame,
655
+ placeholder_text="ML-KEM key file (.pem or .der)",
656
+ fg_color=THEME["box_color"],
657
+ border_color=THEME["box_border"],
658
+ text_color=THEME["content_text"])
659
+ mlkem_key_entry.grid(
660
+ row=0,
661
+ column=0,
662
+ columnspan=1,
663
+ sticky="ew",
664
+ padx=12,
665
+ pady=(
666
+ 2,
667
+ 6))
668
+ self._file_row(mlkem_frame, 0, mlkem_key_entry)
669
+ mlkem_pass_entry = ctk.CTkEntry(
670
+ mlkem_frame,
671
+ placeholder_text="Key passphrase (if any)",
672
+ show="•",
673
+ fg_color=THEME["box_color"],
674
+ border_color=THEME["box_border"],
675
+ text_color=THEME["content_text"])
676
+ mlkem_pass_entry.grid(
677
+ row=1,
678
+ column=0,
679
+ columnspan=2,
680
+ sticky="ew",
681
+ padx=12,
682
+ pady=(
683
+ 0,
684
+ 6))
685
+ ctk.CTkLabel(
686
+ mlkem_frame,
687
+ text="ML-KEM is post-quantum lattice-based cryptography standardized by FIPS 203. "
688
+ "The selected parameter set uses hybrid AES encryption.",
689
+ text_color=THEME["content_text"],
690
+ font=self._font(11),
691
+ justify="left",
692
+ anchor="w",
693
+ wraplength=540).grid(
694
+ row=2,
695
+ column=0,
696
+ columnspan=2,
697
+ sticky="ew",
698
+ padx=12,
699
+ pady=10)
700
+
701
+ adv_fields = {}
702
+ self._advanced(
703
+ page, row, lambda inner: adv_fields.update(
704
+ self._build_config_fields(inner)))
705
+ row += 1
706
+
707
+ config_actions = ctk.CTkFrame(page, fg_color="transparent")
708
+ config_actions.grid(
709
+ row=row,
710
+ column=0,
711
+ columnspan=2,
712
+ sticky="ew",
713
+ pady=(
714
+ 2,
715
+ 8))
716
+
717
+ def apply_config(config):
718
+ constructing_config.__dict__.update(config.__dict__)
719
+ if config.algorithm == "aes":
720
+ key_type_var.set("AES (password)")
721
+ elif config.algorithm == "extended_oaep":
722
+ key_type_var.set("RSA (pub-key auth)")
723
+ rsa_mode_var.set("Hybrid (any file size)")
724
+ elif config.algorithm == "rsa-oaep":
725
+ key_type_var.set("RSA (pub-key auth)")
726
+ rsa_mode_var.set("Direct RSA-OAEP (small files only)")
727
+ elif config.algorithm.startswith("ML-KEM-"):
728
+ key_type_var.set("ML-KEM (Post Quantum, pub-key auth)")
729
+ adv_fields["store_iv"].set(config.store_iv)
730
+ adv_fields["mac_len"].delete(0, "end")
731
+ adv_fields["mac_len"].insert(0, str(config.mac_len))
732
+ adv_fields["kdf_salt"].delete(0, "end")
733
+ adv_fields["kdf_salt"].insert(
734
+ 0, f"base64:{
735
+ base64.b64encode(
736
+ config.kdf_salt).decode('ascii')}")
737
+ adv_fields["kdf_time"].delete(0, "end")
738
+ adv_fields["kdf_time"].insert(0, str(config.kdf_time_cost))
739
+ adv_fields["kdf_mem"].delete(0, "end")
740
+ adv_fields["kdf_mem"].insert(
741
+ 0, str(config.kdf_memory_cost // 1024))
742
+ adv_fields["kdf_par"].delete(0, "end")
743
+ adv_fields["kdf_par"].insert(0, str(config.kdf_parallelism))
744
+ adv_fields["hash"].set(
745
+ AVAIL_HASH_STR[AVAIL_HASH.index(config.hash_func)])
746
+ adv_fields["rand_func"].set(config.rand_func)
747
+ refresh()
748
+
749
+ def generate_config():
750
+ try:
751
+ config = self._config_from_fields(
752
+ adv_fields, allow_empty_salt=True, algorithm=selected_algorithm())
753
+ config.generate_random_salt()
754
+ constructing_config.kdf_salt = config.kdf_salt
755
+ adv_fields["kdf_salt"].delete(0, "end")
756
+ adv_fields["kdf_salt"].insert(
757
+ 0, f"base64:{
758
+ base64.b64encode(
759
+ constructing_config.kdf_salt).decode('ascii')}")
760
+ except GeneralError as error:
761
+ self._error_win("Config generation failed", str(error))
762
+
763
+ def export_config():
764
+ try:
765
+ config = self._config_from_fields(
766
+ adv_fields, algorithm=selected_algorithm())
767
+ constructing_config.__dict__.update(config.__dict__)
768
+ path = filedialog.asksaveasfilename(
769
+ defaultextension=".conf", filetypes=(
770
+ ("ghostbytes config", "*.conf"), ("All files", "*.*")), )
771
+ if path:
772
+ constructing_config.export_file(path)
773
+ status.configure(
774
+ text=f"Config saved to {path}",
775
+ text_color=SUCCESS)
776
+ except (OSError, ValueError) as e:
777
+ self._error_win("Config export failed", str(e))
778
+
779
+ def import_config():
780
+ path = filedialog.askopenfilename(filetypes=(
781
+ ("ghostbytes config", "*.conf"), ("All files", "*.*")), )
782
+ if not path:
783
+ return
784
+ try:
785
+ apply_config(CryptoConfig.import_file(path))
786
+ status.configure(
787
+ text=f"Config loaded from {path}",
788
+ text_color=SUCCESS)
789
+ except (OSError, ValueError, UnicodeDecodeError) as e:
790
+ self._error_win("Config import failed", str(e))
791
+
792
+ generate_button = self._ghost_button(
793
+ config_actions,
794
+ "Generate Config (Random Salt)",
795
+ generate_config,
796
+ width=190)
797
+ export_button = self._ghost_button(
798
+ config_actions, "Export Config", export_config, width=120)
799
+ import_button = self._ghost_button(
800
+ config_actions, "Import Config", import_config, width=120)
801
+ generate_button.grid(row=0, column=0, sticky="w")
802
+ export_button.grid(row=0, column=1, sticky="w", padx=(8, 0))
803
+ import_button.grid(row=0, column=2, sticky="w", padx=(8, 0))
804
+ row += 1
805
+
806
+ btn, bar, status = self._run_row(page, row, "Run", None)
807
+
808
+ def refresh(*_):
809
+ aes_selected = key_type_var.get().startswith("AES")
810
+ mlkem_selected = key_type_var.get().startswith("ML-KEM")
811
+ kdf_visible = aes_selected or mlkem_selected
812
+ for widget in adv_fields.get("kdf_widgets", []):
813
+ if kdf_visible:
814
+ widget.grid()
815
+ else:
816
+ widget.grid_remove()
817
+ for widget in adv_fields.get("hash_widgets", []):
818
+ if key_type_var.get().startswith("RSA"):
819
+ widget.grid()
820
+ else:
821
+ widget.grid_remove()
822
+ for widget in adv_fields.get("rand_widgets", []):
823
+ if not aes_selected:
824
+ widget.grid()
825
+ else:
826
+ widget.grid_remove()
827
+ if mode_var.get() == "Encrypt":
828
+ generate_button.grid()
829
+ export_button.grid()
830
+ import_button.grid_remove()
831
+ else:
832
+ generate_button.grid_remove()
833
+ export_button.grid_remove()
834
+ import_button.grid()
835
+ if aes_selected:
836
+ rsa_frame.grid_forget()
837
+ mlkem_frame.grid_forget()
838
+ pwd_frame.grid(
839
+ row=key_row,
840
+ column=0,
841
+ columnspan=2,
842
+ sticky="ew")
843
+ if mode_var.get() == "Encrypt":
844
+ confirm_entry.grid()
845
+ else:
846
+ confirm_entry.grid_remove()
847
+ elif key_type_var.get().startswith("RSA"):
848
+ pwd_frame.grid_forget()
849
+ mlkem_frame.grid_forget()
850
+ rsa_frame.grid(
851
+ row=key_row,
852
+ column=0,
853
+ columnspan=2,
854
+ sticky="ew")
855
+ else:
856
+ pwd_frame.grid_forget()
857
+ rsa_frame.grid_forget()
858
+ mlkem_frame.grid(
859
+ row=key_row,
860
+ column=0,
861
+ columnspan=2,
862
+ sticky="ew")
863
+ auto_output()
864
+
865
+ refresh()
866
+
867
+ def do_run():
868
+ try:
869
+ config = self._config_from_fields(
870
+ adv_fields, algorithm=selected_algorithm())
871
+ except ValueError as e:
872
+ self._error_win("Invalid advanced settings", str(e))
873
+ return
874
+
875
+ src_paths = selected_inputs or [
876
+ line.strip() for line in in_entry.get().split(",") if line.strip()]
877
+ src, dst = (src_paths[0] if src_paths else ""), out_entry.get()
878
+ if not src or not dst:
879
+ self._error_win(
880
+ "Missing information",
881
+ "Please choose input file(s) and an output file path.")
882
+ return
883
+
884
+ mode = mode_var.get()
885
+
886
+ def launch(key, passphrase):
887
+ if mode == "Encrypt":
888
+ def work():
889
+ return wr.encrypt_paths(
890
+ src_paths, dst, config, key, passphrase)
891
+ else:
892
+ def work():
893
+ return wr.decrypt_paths(
894
+ src_paths, dst, config, key, passphrase)
895
+ self._run_async(
896
+ work,
897
+ btn,
898
+ bar,
899
+ status,
900
+ on_success=lambda res: status.configure(
901
+ text=f"Saved to {res}",
902
+ text_color=SUCCESS),
903
+ start_msg="Encrypting…" if mode == "Encrypt" else "Decrypting…",
904
+ )
905
+
906
+ if key_type_var.get().startswith("AES"):
907
+ pw = pwd_entry.get()
908
+ if not pw:
909
+ self._error_win(
910
+ "Missing information",
911
+ "Please enter a password.")
912
+ return
913
+ if mode == "Encrypt" and pw != confirm_entry.get():
914
+ self._error_win(
915
+ "Password mismatch",
916
+ "Password and confirmation do not match.")
917
+ return
918
+ config.algorithm = "aes"
919
+ launch(pw.encode("utf-8"), None)
920
+ elif key_type_var.get().startswith("RSA"):
921
+ key_path = key_entry.get()
922
+ if not key_path:
923
+ self._error_win(
924
+ "Missing information",
925
+ "Please choose an RSA key file.")
926
+ return
927
+ algorithm = (
928
+ "extended_oaep"
929
+ if rsa_mode_var.get().startswith("Hybrid")
930
+ else "rsa-oaep")
931
+ try:
932
+ with open(key_path, "rb") as f:
933
+ key = f.read()
934
+ except OSError as e:
935
+ self._error_win("File error", str(e))
936
+ return
937
+ passphrase = pass_entry.get() or None
938
+ config.algorithm = algorithm
939
+ launch(key, passphrase)
940
+ else:
941
+ key_path = mlkem_key_entry.get()
942
+ if not key_path:
943
+ self._error_win(
944
+ "Missing information",
945
+ "Please choose an ML-KEM key file.")
946
+ return
947
+ try:
948
+ with open(key_path, "rb") as f:
949
+ key = f.read()
950
+ except OSError as e:
951
+ self._error_win("File error", str(e))
952
+ return
953
+ try:
954
+ algorithm = wr.detect_mlkem_algorithm(
955
+ key, mlkem_pass_entry.get() or None)
956
+ except Exception as e:
957
+ self._error_win("Key error", str(e))
958
+ return
959
+ config.algorithm = algorithm
960
+ launch(key, mlkem_pass_entry.get() or None)
961
+
962
+ btn.configure(command=do_run)
963
+
964
+ # ------------------------------------------------------------------ #
965
+ # RSA key generation / verification / info
966
+ # ------------------------------------------------------------------ #
967
+
968
+ def _build_genkey(self):
969
+ self._page_header(
970
+ "key",
971
+ "Generate Key Pair",
972
+ "Create an RSA or ML-KEM public/private key pair.")
973
+ page = self._page(row=1)
974
+ row = 0
975
+ self._section(page, row, "Key type")
976
+ row += 1
977
+ key_type_var = ctk.StringVar(value="RSA")
978
+ ctk.CTkSegmentedButton(
979
+ page,
980
+ values=[
981
+ "ML-KEM",
982
+ "RSA"],
983
+ variable=key_type_var,
984
+ fg_color=THEME["box_color"],
985
+ selected_color=ACCENT,
986
+ selected_hover_color=ACCENT_HOVER,
987
+ unselected_color=THEME["box_color"],
988
+ text_color=THEME["content_text"],
989
+ height=38,
990
+ font=self._font(
991
+ 13,
992
+ "bold"),
993
+ command=lambda _value: refresh_type(),
994
+ ).grid(
995
+ row=row,
996
+ column=0,
997
+ columnspan=2,
998
+ sticky="ew")
999
+ row += 1
1000
+
1001
+ rsa_frame = ctk.CTkFrame(page, fg_color="transparent")
1002
+ rsa_frame.grid_columnconfigure(0, weight=1)
1003
+ self._field_label(rsa_frame, 0, "RSA key size")
1004
+ rsa_size_var, _ = self._option(
1005
+ rsa_frame, 1, [str(s) for s in COMMON_RSA_SIZE] + ["Custom…"],
1006
+ default="2048", command=lambda _value: refresh_rsa_size()
1007
+ )
1008
+ custom_rsa_entry = self._entry(
1009
+ rsa_frame, 2, "Custom RSA key size in bits")
1010
+ custom_rsa_entry.grid_remove()
1011
+ self._field_label(rsa_frame, 3, "Public exponent")
1012
+ exponent_entry = self._entry(rsa_frame, 4, default="65537")
1013
+ self._field_label(rsa_frame, 5, "Output format")
1014
+ rsa_format_var, _ = self._option(rsa_frame, 6, RSA_KEY_OUT_FORMAT)
1015
+
1016
+ mlkem_frame = ctk.CTkFrame(page, fg_color="transparent")
1017
+ mlkem_frame.grid_columnconfigure(0, weight=1)
1018
+ self._field_label(mlkem_frame, 0, "ML-KEM parameter set")
1019
+ mlkem_values = [
1020
+ algorithm for algorithm in AVAIL_ALG if algorithm.startswith("ML-KEM-")]
1021
+ mlkem_alg_var, _ = self._option(mlkem_frame, 1, mlkem_values)
1022
+ ctk.CTkLabel(
1023
+ mlkem_frame,
1024
+ text=(
1025
+ "ML-KEM-768 corresponds to Post-Quantum Security Level 3; "
1026
+ "ML-KEM-1024 corresponds to Level 5 (NIST) "
1027
+ "(Equivalent to AES-256)."),
1028
+ text_color=THEME["gray_text"],
1029
+ font=self._font(10),
1030
+ anchor="w",
1031
+ justify="left",
1032
+ wraplength=560).grid(
1033
+ row=2,
1034
+ column=0,
1035
+ sticky="w",
1036
+ pady=(
1037
+ 4,
1038
+ 0))
1039
+ self._field_label(mlkem_frame, 3, "Output format")
1040
+ mlkem_format_var, _ = self._option(mlkem_frame, 4, ["PEM", "DER"])
1041
+
1042
+ rsa_frame.grid(row=row, column=0, columnspan=2, sticky="ew")
1043
+ row += 1
1044
+ self._field_label(
1045
+ page, row, "Passphrase (optional, protects private key)")
1046
+ row += 1
1047
+ pass_entry = self._entry(
1048
+ page, row, "Leave blank for no passphrase", show="•")
1049
+ row += 1
1050
+ self._section(page, row, "Save to")
1051
+ row += 1
1052
+ self._field_label(page, row, "Private key file")
1053
+ row += 1
1054
+ private_entry = self._entry(
1055
+ page, row, "Browse for the private key output file")
1056
+ self._file_row(page, row, private_entry, save=True,
1057
+ on_browse=lambda: derive_public_path())
1058
+ row += 1
1059
+ self._field_label(page, row, "Public key file")
1060
+ row += 1
1061
+ public_entry = self._entry(page, row, "Public key output file")
1062
+ self._file_row(page, row, public_entry, save=True,
1063
+ on_browse=lambda: derive_private_from_public())
1064
+ row += 1
1065
+
1066
+ def derive_public_path(_event=None):
1067
+ private_path = private_entry.get()
1068
+ if private_path:
1069
+ stem, extension = os.path.splitext(private_path)
1070
+ public_entry.delete(0, "end")
1071
+ public_entry.insert(0, f"{stem}_pub{extension}")
1072
+
1073
+ def derive_private_from_public(_event=None):
1074
+ public_path = public_entry.get()
1075
+ if public_path:
1076
+ stem, extension = os.path.splitext(public_path)
1077
+ private_entry.delete(0, "end")
1078
+ private_entry.insert(
1079
+ 0, f"{stem[:-4] if stem.endswith('_pub') else stem}{extension}")
1080
+
1081
+ private_entry.bind("<KeyRelease>", derive_public_path)
1082
+ private_entry.bind("<FocusOut>", derive_public_path)
1083
+ btn, bar, status = self._run_row(page, row, "Generate", None)
1084
+
1085
+ def refresh_type():
1086
+ if key_type_var.get() == "RSA":
1087
+ mlkem_frame.grid_forget()
1088
+ rsa_frame.grid(row=2, column=0, columnspan=2, sticky="ew")
1089
+ else:
1090
+ rsa_frame.grid_forget()
1091
+ mlkem_frame.grid(row=2, column=0, columnspan=2, sticky="ew")
1092
+
1093
+ def refresh_rsa_size():
1094
+ if rsa_size_var.get() == "Custom…":
1095
+ custom_rsa_entry.grid()
1096
+ else:
1097
+ custom_rsa_entry.grid_remove()
1098
+
1099
+ def do_run():
1100
+ priv_path = private_entry.get().strip()
1101
+ pub_path = public_entry.get().strip()
1102
+ if not priv_path or not pub_path:
1103
+ self._error_win(
1104
+ "Missing information",
1105
+ "Please choose private and public key output files.")
1106
+ return
1107
+
1108
+ key_size = None
1109
+ if key_type_var.get() == "RSA":
1110
+ try:
1111
+ key_size = int(
1112
+ custom_rsa_entry.get()) if rsa_size_var.get() == "Custom…" else int(
1113
+ rsa_size_var.get())
1114
+ except ValueError:
1115
+ self._error_win(
1116
+ "Invalid input",
1117
+ "RSA key size must be numeric.")
1118
+ return
1119
+
1120
+ def work():
1121
+ if key_type_var.get() == "RSA":
1122
+ pub, priv = wr.generate_rsa_keypair(key_size, int(
1123
+ exponent_entry.get()), pass_entry.get() or None, rsa_format_var.get())
1124
+ else:
1125
+ pub, priv = wr.generate_mlkem_keypair(
1126
+ mlkem_alg_var.get(), mlkem_format_var.get(), pass_entry.get() or None)
1127
+ os.makedirs(os.path.dirname(priv_path) or ".", exist_ok=True)
1128
+ with open(pub_path, "wb") as file:
1129
+ file.write(pub)
1130
+ with open(priv_path, "wb") as file:
1131
+ file.write(priv)
1132
+ return pub_path, priv_path
1133
+
1134
+ def launch():
1135
+ self._run_async(
1136
+ work,
1137
+ btn,
1138
+ bar,
1139
+ status,
1140
+ on_success=lambda result: status.configure(
1141
+ text=f"Saved {
1142
+ os.path.basename(
1143
+ result[0])} and {
1144
+ os.path.basename(
1145
+ result[1])}",
1146
+ text_color=SUCCESS),
1147
+ start_msg="Generating key pair…",
1148
+ )
1149
+
1150
+ if key_size is None:
1151
+ launch()
1152
+ else:
1153
+ self._confirm_large_rsa(key_size, launch)
1154
+
1155
+ btn.configure(command=do_run)
1156
+ refresh_type()
1157
+
1158
+ def _build_verifykey(self):
1159
+ self._page_header(
1160
+ "circle-check",
1161
+ "Verify Key Pair",
1162
+ "Check whether a public and private key match; key types are detected automatically.")
1163
+ page = self._page(row=1)
1164
+
1165
+ row = 0
1166
+ self._field_label(page, row, "Public key file")
1167
+ row += 1
1168
+ pub_entry = self._entry(page, row, "Public key (.pem)")
1169
+ self._file_row(
1170
+ page,
1171
+ row,
1172
+ pub_entry,
1173
+ on_browse=lambda: derive_private_path())
1174
+ row += 1
1175
+
1176
+ self._field_label(page, row, "Private key file")
1177
+ row += 1
1178
+ priv_entry = self._entry(page, row, "Private key (.pem)")
1179
+ self._file_row(
1180
+ page,
1181
+ row,
1182
+ priv_entry,
1183
+ on_browse=lambda: derive_public_path())
1184
+ row += 1
1185
+
1186
+ def derive_private_path(_event=None):
1187
+ public_path = pub_entry.get()
1188
+ if public_path:
1189
+ stem, extension = os.path.splitext(public_path)
1190
+ private_entry_value = f"{stem[:-
1191
+ 4] if stem.endswith('_pub') else stem}{extension}"
1192
+ priv_entry.delete(0, "end")
1193
+ priv_entry.insert(0, private_entry_value)
1194
+
1195
+ def derive_public_path(_event=None):
1196
+ private_path = priv_entry.get()
1197
+ if private_path:
1198
+ stem, extension = os.path.splitext(private_path)
1199
+ pub_entry.delete(0, "end")
1200
+ pub_entry.insert(0, f"{stem}_pub{extension}")
1201
+
1202
+ pub_entry.bind("<KeyRelease>", derive_private_path)
1203
+ pub_entry.bind("<FocusOut>", derive_private_path)
1204
+ priv_entry.bind("<KeyRelease>", derive_public_path)
1205
+ priv_entry.bind("<FocusOut>", derive_public_path)
1206
+
1207
+ self._field_label(page, row, "Passphrase (if any)")
1208
+ row += 1
1209
+ pass_entry = self._entry(page, row, show="•")
1210
+ row += 1
1211
+
1212
+ btn, bar, status = self._run_row(page, row, "Verify", None)
1213
+
1214
+ def do_run():
1215
+ pub_path, priv_path = pub_entry.get(), priv_entry.get()
1216
+ if not pub_path or not priv_path:
1217
+ self._error_win(
1218
+ "Missing information",
1219
+ "Please choose both key files.")
1220
+ return
1221
+
1222
+ def work():
1223
+ with open(pub_path, "rb") as f:
1224
+ pub = f.read()
1225
+ with open(priv_path, "rb") as f:
1226
+ priv = f.read()
1227
+ return wr.verify_keypair(pub, priv, pass_entry.get() or None)
1228
+
1229
+ def on_success(matches):
1230
+ if matches:
1231
+ status.configure(
1232
+ text="✓ The key pair matches.",
1233
+ text_color=SUCCESS)
1234
+ else:
1235
+ status.configure(
1236
+ text="✗ The key pair does NOT match.",
1237
+ text_color=DANGER)
1238
+
1239
+ self._run_async(
1240
+ work,
1241
+ btn,
1242
+ bar,
1243
+ status,
1244
+ on_success=on_success,
1245
+ start_msg="Verifying…")
1246
+
1247
+ btn.configure(command=do_run)
1248
+
1249
+ def _build_keyinfo(self):
1250
+ self._page_header(
1251
+ "key",
1252
+ "Key Information",
1253
+ "Inspect a key and check whether it is cryptographically usable.")
1254
+ page = self._page(row=1)
1255
+
1256
+ row = 0
1257
+ self._field_label(page, row, "Key file")
1258
+ row += 1
1259
+ key_entry = self._entry(page, row, "Public or private key (.pem/.der)")
1260
+ self._file_row(page, row, key_entry)
1261
+ row += 1
1262
+
1263
+ self._field_label(page, row, "Passphrase (if any)")
1264
+ row += 1
1265
+ pass_entry = self._entry(page, row, show="•")
1266
+ row += 1
1267
+
1268
+ btn, bar, status = self._run_row(page, row, "Inspect", None)
1269
+ row += 2
1270
+
1271
+ result_box = ctk.CTkFrame(
1272
+ page,
1273
+ fg_color=THEME["box_color"],
1274
+ border_width=1,
1275
+ border_color=THEME["box_border"],
1276
+ corner_radius=THEME["corner_rad"])
1277
+ result_box.grid(
1278
+ row=row,
1279
+ column=0,
1280
+ columnspan=2,
1281
+ sticky="ew",
1282
+ pady=(
1283
+ 6,
1284
+ 0))
1285
+ result_box.grid_columnconfigure(1, weight=1)
1286
+ result_box.grid_remove()
1287
+
1288
+ def do_run():
1289
+ path = key_entry.get()
1290
+ if not path:
1291
+ self._error_win(
1292
+ "Missing information",
1293
+ "Please choose a key file.")
1294
+ return
1295
+
1296
+ def work():
1297
+ with open(path, "rb") as f:
1298
+ data = f.read()
1299
+ return wr.key_info(data, pass_entry.get() or None)
1300
+
1301
+ def on_success(info):
1302
+ for child in result_box.winfo_children():
1303
+ child.destroy()
1304
+ for i, (k, v) in enumerate(info.items()):
1305
+ ctk.CTkLabel(
1306
+ result_box,
1307
+ text=k,
1308
+ text_color=THEME["slight_gray"],
1309
+ font=self._font(12)).grid(
1310
+ row=i,
1311
+ column=0,
1312
+ sticky="w",
1313
+ padx=14,
1314
+ pady=8)
1315
+ ctk.CTkLabel(
1316
+ result_box,
1317
+ text=v,
1318
+ text_color=THEME["content_text"],
1319
+ font=self._mono_font(12)).grid(
1320
+ row=i,
1321
+ column=1,
1322
+ sticky="w",
1323
+ padx=14,
1324
+ pady=8)
1325
+ result_box.grid()
1326
+
1327
+ self._run_async(
1328
+ work,
1329
+ btn,
1330
+ bar,
1331
+ status,
1332
+ on_success=on_success,
1333
+ start_msg="Reading key…")
1334
+
1335
+ btn.configure(command=do_run)
1336
+
1337
+ # ------------------------------------------------------------------ #
1338
+ # Hash files (single, multi-file, or whole folder), Random, Password
1339
+ # generator, Benchmark
1340
+ # ------------------------------------------------------------------ #
1341
+
1342
+ def _build_hashfile(self):
1343
+ self._page_header(
1344
+ "hashtag",
1345
+ "Hash File(s) (Checksum)",
1346
+ "Compute a cryptographic digest for files or an entire folder.")
1347
+ page = self._page(row=1)
1348
+
1349
+ row = 0
1350
+ self._section(page, row, "Source")
1351
+ row += 1
1352
+ mode_var = ctk.StringVar(value="Files")
1353
+ ctk.CTkSegmentedButton(
1354
+ page,
1355
+ values=[
1356
+ "Files",
1357
+ "Folder"],
1358
+ variable=mode_var,
1359
+ fg_color=THEME["box_color"],
1360
+ selected_color=ACCENT,
1361
+ selected_hover_color=ACCENT_HOVER,
1362
+ unselected_color=THEME["box_color"],
1363
+ text_color=THEME["content_text"],
1364
+ command=lambda v: refresh()).grid(
1365
+ row=row,
1366
+ column=0,
1367
+ columnspan=2,
1368
+ sticky="w",
1369
+ pady=(
1370
+ 0,
1371
+ 6))
1372
+ row += 1
1373
+
1374
+ self._field_label(page, row, "Hash algorithm")
1375
+ row += 1
1376
+ alg_var, _ = self._option(page, row, AVAIL_HASH_STR)
1377
+ row += 1
1378
+ mode_row = row
1379
+ row += 1
1380
+
1381
+ files_frame = ctk.CTkFrame(page, fg_color="transparent")
1382
+ files_frame.grid_columnconfigure(0, weight=1)
1383
+ files_box = ctk.CTkTextbox(
1384
+ files_frame,
1385
+ height=70,
1386
+ fg_color=THEME["box_color"],
1387
+ text_color=THEME["content_text"],
1388
+ font=self._mono_font(11))
1389
+ files_box.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 6))
1390
+ files_box.configure(state="disabled")
1391
+ selected = []
1392
+
1393
+ def browse_files():
1394
+ paths = filedialog.askopenfilenames()
1395
+ if paths:
1396
+ selected.clear()
1397
+ selected.extend(paths)
1398
+ files_box.configure(state="normal")
1399
+ files_box.delete("1.0", "end")
1400
+ files_box.insert("1.0", "\n".join(selected))
1401
+ files_box.configure(state="disabled")
1402
+ refresh_checksum_path()
1403
+
1404
+ self._ghost_button(
1405
+ files_frame,
1406
+ "Choose files…",
1407
+ browse_files).grid(
1408
+ row=1,
1409
+ column=1,
1410
+ sticky="e")
1411
+
1412
+ folder_frame = ctk.CTkFrame(page, fg_color="transparent")
1413
+ folder_frame.grid_columnconfigure(0, weight=1)
1414
+ folder_entry = ctk.CTkEntry(
1415
+ folder_frame,
1416
+ placeholder_text="Folder to hash (recursive)",
1417
+ fg_color=THEME["box_color"],
1418
+ border_color=THEME["box_border"],
1419
+ text_color=THEME["content_text"])
1420
+ folder_entry.grid(row=0, column=0, sticky="ew")
1421
+ self._file_row(folder_frame, 0, folder_entry, dir_only=True)
1422
+
1423
+ def refresh(*_):
1424
+ if mode_var.get() == "Files":
1425
+ folder_frame.grid_forget()
1426
+ files_frame.grid(
1427
+ row=mode_row,
1428
+ column=0,
1429
+ columnspan=2,
1430
+ sticky="ew")
1431
+ else:
1432
+ files_frame.grid_forget()
1433
+ folder_frame.grid(
1434
+ row=mode_row,
1435
+ column=0,
1436
+ columnspan=2,
1437
+ sticky="ew")
1438
+
1439
+ refresh()
1440
+
1441
+ self._section(page, row, "Output")
1442
+ row += 1
1443
+ save_var = self._checkbox(page, row, "Save results to a checksum file")
1444
+ row += 1
1445
+ out_entry = self._entry(page, row, "Where to save the checksum file")
1446
+ browse_btn = self._file_row(page, row, out_entry, save=True)
1447
+ out_entry.grid_remove()
1448
+ browse_btn.grid_remove()
1449
+ row += 1
1450
+
1451
+ def toggle_save(*_):
1452
+ if save_var.get():
1453
+ out_entry.grid()
1454
+ browse_btn.grid()
1455
+ refresh_checksum_path()
1456
+ else:
1457
+ out_entry.grid_remove()
1458
+ browse_btn.grid_remove()
1459
+
1460
+ def refresh_checksum_path(*_):
1461
+ if not save_var.get():
1462
+ return
1463
+ if mode_var.get() == "Files" and selected:
1464
+ base = wr.checksum_root(selected)
1465
+ elif mode_var.get() == "Folder" and folder_entry.get():
1466
+ base = wr.checksum_root([folder_entry.get()])
1467
+ else:
1468
+ base = "."
1469
+ out_entry.delete(0, "end")
1470
+ out_entry.insert(
1471
+ 0, os.path.join(
1472
+ base, wr.checksum_suffix(
1473
+ alg_var.get())))
1474
+
1475
+ save_var.trace_add("write", lambda *_: toggle_save())
1476
+ alg_var.trace_add("write", refresh_checksum_path)
1477
+ folder_entry.bind("<KeyRelease>", refresh_checksum_path)
1478
+
1479
+ bar = ctk.CTkProgressBar(
1480
+ page, mode="determinate", progress_color=ACCENT)
1481
+ bar.set(0)
1482
+ bar.grid(row=row, column=0, columnspan=2, sticky="ew", pady=(10, 0))
1483
+ row += 1
1484
+ status = ctk.CTkLabel(
1485
+ page,
1486
+ text="",
1487
+ text_color=THEME["slight_gray"],
1488
+ font=self._font(11),
1489
+ anchor="w")
1490
+ status.grid(row=row, column=0, sticky="w", pady=(6, 0))
1491
+ btn = self._primary_button(page, "Compute Hashes")
1492
+ btn.grid(row=row, column=1, sticky="e", pady=(6, 0))
1493
+ row += 1
1494
+
1495
+ results_box = ctk.CTkTextbox(
1496
+ page,
1497
+ height=180,
1498
+ fg_color=THEME["box_color"],
1499
+ text_color=THEME["content_text"],
1500
+ font=self._mono_font(11))
1501
+ results_box.grid(
1502
+ row=row,
1503
+ column=0,
1504
+ columnspan=2,
1505
+ sticky="ew",
1506
+ pady=(
1507
+ 10,
1508
+ 0))
1509
+ results_box.configure(state="disabled")
1510
+
1511
+ def do_run():
1512
+ alg = alg_var.get()
1513
+ if mode_var.get() == "Files":
1514
+ if not selected:
1515
+ self._error_win(
1516
+ "Missing information",
1517
+ "Please choose at least one file.")
1518
+ return
1519
+ entries = [(os.path.basename(p), p) for p in selected]
1520
+ else:
1521
+ folder = folder_entry.get()
1522
+ if not folder:
1523
+ self._error_win(
1524
+ "Missing information",
1525
+ "Please choose a folder.")
1526
+ return
1527
+ try:
1528
+ entries = wr.collect_folder_files(folder)
1529
+ except Exception as e:
1530
+ self._error_win("Folder error", str(e))
1531
+ return
1532
+ if not entries:
1533
+ self._error_win(
1534
+ "Empty folder",
1535
+ "No files were found in that folder.")
1536
+ return
1537
+
1538
+ save_checksum = save_var.get()
1539
+ out_path = out_entry.get() if save_checksum else None
1540
+ if save_checksum and not out_path:
1541
+ self._error_win(
1542
+ "Missing information",
1543
+ "Please choose where to save the checksum file.")
1544
+ return
1545
+
1546
+ btn.configure(state="disabled")
1547
+ bar.set(0)
1548
+ results_box.configure(state="normal")
1549
+ results_box.delete("1.0", "end")
1550
+ results_box.configure(state="disabled")
1551
+ status.configure(text="Starting…", text_color=THEME["slight_gray"])
1552
+
1553
+ def progress_cb(done, total, label):
1554
+ self.after(
1555
+ 0,
1556
+ lambda: (
1557
+ bar.set(
1558
+ done / total),
1559
+ status.configure(
1560
+ text=f"{done}/{total}: {label}")))
1561
+
1562
+ def target():
1563
+ try:
1564
+ hash_entries = (
1565
+ wr.checksum_entries_for_output(entries, out_path)
1566
+ if save_checksum else entries
1567
+ )
1568
+ results = wr.hash_paths(
1569
+ hash_entries, alg, progress_cb=progress_cb)
1570
+ if save_checksum:
1571
+ wr.write_checksum_file(out_path, results)
1572
+ except Exception as e:
1573
+ message = str(e)
1574
+ self.after(
1575
+ 0, lambda: (
1576
+ btn.configure(
1577
+ state="normal"), status.configure(
1578
+ text=f"Failed: {message}", text_color=DANGER), self._error_win(
1579
+ "Operation Failed", message)))
1580
+ return
1581
+
1582
+ def finish():
1583
+ btn.configure(state="normal")
1584
+ bar.set(1.0)
1585
+ results_box.configure(state="normal")
1586
+ results_box.delete("1.0", "end")
1587
+ for label, digest in results:
1588
+ results_box.insert("end", f"{digest} {label}\n")
1589
+ results_box.configure(state="disabled")
1590
+ msg = f"Done — {len(results)} file(s) hashed."
1591
+ if save_checksum:
1592
+ msg += f" Saved to {out_path}"
1593
+ status.configure(text=msg, text_color=SUCCESS)
1594
+
1595
+ self.after(0, finish)
1596
+
1597
+ threading.Thread(target=target, daemon=True).start()
1598
+
1599
+ btn.configure(command=do_run)
1600
+
1601
+ def _build_random(self):
1602
+ self._page_header(
1603
+ "dice",
1604
+ "Random",
1605
+ "Generate cryptographically random data.")
1606
+ page = self._page(row=1)
1607
+
1608
+ row = 0
1609
+ self._field_label(page, row, "Source")
1610
+ row += 1
1611
+ src_var, _ = self._option(page, row, AVAIL_RANDOM_STR)
1612
+ row += 1
1613
+
1614
+ self._field_label(page, row, "Length (bytes)")
1615
+ row += 1
1616
+ len_entry = self._entry(page, row, default="32")
1617
+ row += 1
1618
+
1619
+ self._field_label(page, row, "Output format")
1620
+ row += 1
1621
+ out_var, _ = self._option(page, row, ["Hex", "Base64", "Save to file"])
1622
+ row += 1
1623
+
1624
+ out_entry = self._entry(page, row, "Where to save the random data")
1625
+ browse_btn = self._file_row(page, row, out_entry, save=True)
1626
+ out_entry.grid_remove()
1627
+ browse_btn.grid_remove()
1628
+ row += 1
1629
+
1630
+ def toggle_output(*_):
1631
+ if out_var.get() == "Save to file":
1632
+ out_entry.grid()
1633
+ browse_btn.grid()
1634
+ else:
1635
+ out_entry.grid_remove()
1636
+ browse_btn.grid_remove()
1637
+
1638
+ out_var.trace_add("write", lambda *_: toggle_output())
1639
+
1640
+ btn, bar, status = self._run_row(page, row, "Generate", None)
1641
+ row += 2
1642
+
1643
+ result_box = ctk.CTkTextbox(
1644
+ page,
1645
+ height=90,
1646
+ fg_color=THEME["box_color"],
1647
+ text_color=THEME["content_text"],
1648
+ font=self._mono_font(12))
1649
+ result_box.grid(
1650
+ row=row,
1651
+ column=0,
1652
+ columnspan=2,
1653
+ sticky="ew",
1654
+ pady=(
1655
+ 0,
1656
+ 0))
1657
+ result_box.configure(state="disabled")
1658
+
1659
+ toggle_output()
1660
+
1661
+ def do_run():
1662
+ try:
1663
+ length = int(len_entry.get())
1664
+ except ValueError:
1665
+ self._error_win(
1666
+ "Invalid input",
1667
+ "Length must be a whole number.")
1668
+ return
1669
+
1670
+ def work():
1671
+ return wr.random_bytes(src_var.get(), length)
1672
+
1673
+ def on_success(data):
1674
+ fmt = out_var.get()
1675
+ if fmt == "Save to file":
1676
+ path = out_entry.get()
1677
+ if not path:
1678
+ status.configure(
1679
+ text="No output file chosen — showing as hex instead.",
1680
+ text_color=WARNING)
1681
+ text = data.hex()
1682
+ else:
1683
+ with open(path, "wb") as f:
1684
+ f.write(data)
1685
+ status.configure(
1686
+ text=f"Saved to {path}", text_color=SUCCESS)
1687
+ return
1688
+ elif fmt == "Base64":
1689
+ text = base64.b64encode(data).decode("ascii")
1690
+ else:
1691
+ text = data.hex()
1692
+ result_box.configure(state="normal")
1693
+ result_box.delete("1.0", "end")
1694
+ result_box.insert("1.0", text)
1695
+ result_box.configure(state="disabled")
1696
+
1697
+ self._run_async(
1698
+ work,
1699
+ btn,
1700
+ bar,
1701
+ status,
1702
+ on_success=on_success,
1703
+ start_msg="Generating…")
1704
+
1705
+ btn.configure(command=do_run)
1706
+
1707
+ def _build_pwdgen(self):
1708
+ self._page_header(
1709
+ "dice",
1710
+ "Password Generator",
1711
+ "Create a strong, random passphrase.")
1712
+ page = self._page(row=1)
1713
+
1714
+ row = 0
1715
+ self._field_label(page, row, "Length (characters)")
1716
+ row += 1
1717
+ len_entry = self._entry(page, row, default="16")
1718
+ row += 1
1719
+
1720
+ self._field_label(page, row, "Random source")
1721
+ row += 1
1722
+ src_var, _ = self._option(
1723
+ page, row, AVAIL_RANDOM_STR, command=lambda v: try_generate(False))
1724
+ row += 1
1725
+
1726
+ upper_var = self._checkbox(page, row, "Uppercase letters (A–Z)", True)
1727
+ row += 1
1728
+ lower_var = self._checkbox(page, row, "Lowercase letters (a–z)", True)
1729
+ row += 1
1730
+ digit_var = self._checkbox(page, row, "Digits (0–9)", True)
1731
+ row += 1
1732
+ symbol_var = self._checkbox(page, row, "Symbols (!@#$…)", True)
1733
+ row += 1
1734
+
1735
+ result_entry = ctk.CTkEntry(
1736
+ page,
1737
+ fg_color=THEME["box_color"],
1738
+ border_color=THEME["box_border"],
1739
+ text_color=THEME["content_text"],
1740
+ font=self._mono_font(14))
1741
+ result_entry.grid(row=row, column=0, sticky="ew", pady=(10, 0))
1742
+
1743
+ def copy():
1744
+ self.clipboard_clear()
1745
+ self.clipboard_append(result_entry.get())
1746
+ status.configure(text="Copied to clipboard.", text_color=SUCCESS)
1747
+
1748
+ self._ghost_button(
1749
+ page, "Copy", copy, width=70).grid(
1750
+ row=row, column=1, sticky="w", padx=(
1751
+ 8, 0), pady=(
1752
+ 10, 0))
1753
+ row += 1
1754
+
1755
+ status = ctk.CTkLabel(
1756
+ page,
1757
+ text="",
1758
+ text_color=THEME["slight_gray"],
1759
+ font=self._font(11),
1760
+ anchor="w")
1761
+ status.grid(row=row, column=0, sticky="w", pady=(6, 0))
1762
+
1763
+ def try_generate(show_errors):
1764
+ try:
1765
+ length = int(len_entry.get())
1766
+ except ValueError:
1767
+ if show_errors:
1768
+ self._error_win(
1769
+ "Invalid input",
1770
+ "Length must be a whole number.")
1771
+ return
1772
+ try:
1773
+ pwd = wr.generate_password(
1774
+ length,
1775
+ upper_var.get(),
1776
+ lower_var.get(),
1777
+ digit_var.get(),
1778
+ symbol_var.get(),
1779
+ random_source=src_var.get())
1780
+ except GeneralError as e:
1781
+ if show_errors:
1782
+ self._error_win("Cannot generate", str(e))
1783
+ return
1784
+ result_entry.delete(0, "end")
1785
+ result_entry.insert(0, pwd)
1786
+ status.configure(text="", text_color=THEME["slight_gray"])
1787
+
1788
+ self._primary_button(
1789
+ page,
1790
+ "Regenerate",
1791
+ lambda: try_generate(True)).grid(
1792
+ row=row,
1793
+ column=1,
1794
+ sticky="e",
1795
+ pady=(
1796
+ 6,
1797
+ 0))
1798
+
1799
+ for var in (upper_var, lower_var, digit_var, symbol_var):
1800
+ var.trace_add("write", lambda *_: try_generate(False))
1801
+ len_entry.bind("<KeyRelease>", lambda e: try_generate(False))
1802
+
1803
+ try_generate(True)
1804
+
1805
+ def _build_benchmark(self):
1806
+ self._page_header(
1807
+ "gauge-high",
1808
+ "Benchmark",
1809
+ "Measure this machine's cryptographic performance.")
1810
+ page = self._page(row=1)
1811
+
1812
+ row = 0
1813
+ self._field_label(page, row, "Data size (bytes, default 1024)")
1814
+ row += 1
1815
+ len_entry = self._entry(page, row, default="1024")
1816
+ row += 1
1817
+ self._field_label(page, row, "RSA key size")
1818
+ row += 1
1819
+ rsa_var, _ = self._option(
1820
+ page, row, [
1821
+ str(size) for size in COMMON_RSA_SIZE], default="2048")
1822
+ row += 1
1823
+ ctk.CTkLabel(
1824
+ page,
1825
+ text="Direct RSA-OAEP cannot benchmark messages larger than the selected key permits; "
1826
+ "those rows will report an error instead of a timing.",
1827
+ text_color=THEME["gray_text"],
1828
+ font=self._font(10),
1829
+ anchor="w",
1830
+ justify="left",
1831
+ wraplength=560).grid(
1832
+ row=row,
1833
+ column=0,
1834
+ columnspan=2,
1835
+ sticky="w",
1836
+ pady=(
1837
+ 0,
1838
+ 4))
1839
+ row += 1
1840
+
1841
+ bar = ctk.CTkProgressBar(
1842
+ page, mode="determinate", progress_color=ACCENT)
1843
+ bar.set(0)
1844
+ bar.grid(row=row, column=0, columnspan=2, sticky="ew", pady=(10, 0))
1845
+ row += 1
1846
+ status = ctk.CTkLabel(
1847
+ page,
1848
+ text="",
1849
+ text_color=THEME["slight_gray"],
1850
+ font=self._font(11),
1851
+ anchor="w")
1852
+ status.grid(row=row, column=0, sticky="w", pady=(6, 8))
1853
+ btn = self._primary_button(page, "Run Benchmark")
1854
+ btn.grid(row=row, column=1, sticky="e", pady=(6, 8))
1855
+ row += 1
1856
+
1857
+ results_box = ctk.CTkTextbox(
1858
+ page,
1859
+ height=260,
1860
+ fg_color=THEME["box_color"],
1861
+ text_color=THEME["content_text"],
1862
+ font=self._mono_font(12))
1863
+ results_box.grid(
1864
+ row=row,
1865
+ column=0,
1866
+ columnspan=2,
1867
+ sticky="ew",
1868
+ pady=(
1869
+ 0,
1870
+ 0))
1871
+ results_box.configure(state="disabled")
1872
+
1873
+ def do_run():
1874
+ try:
1875
+ length = int(len_entry.get())
1876
+ keysize = int(rsa_var.get())
1877
+ except ValueError:
1878
+ self._error_win("Invalid input",
1879
+ "Data size and key size must be numeric.")
1880
+ return
1881
+
1882
+ def launch():
1883
+ algorithms = AVAIL_HASH_STR + AVAIL_ALG + AVAIL_RANDOM_STR
1884
+ btn.configure(state="disabled")
1885
+ bar.set(0)
1886
+ results_box.configure(state="normal")
1887
+ results_box.delete("1.0", "end")
1888
+ results_box.configure(state="disabled")
1889
+
1890
+ def target():
1891
+ for i, alg in enumerate(algorithms):
1892
+ try:
1893
+ result = wr.run_benchmark(alg, length, keysize)
1894
+ ok = True
1895
+ except Exception as e:
1896
+ result = str(e)
1897
+ ok = False
1898
+ self.after(
1899
+ 0,
1900
+ lambda alg=alg, result=result, ok=ok, i=i: self._benchmark_row(
1901
+ results_box, bar, status, alg, result, ok, i + 1, len(algorithms)
1902
+ )
1903
+ )
1904
+ self.after(0, lambda: btn.configure(state="normal"))
1905
+
1906
+ threading.Thread(target=target, daemon=True).start()
1907
+
1908
+ self._confirm_large_rsa(keysize, launch)
1909
+
1910
+ btn.configure(command=do_run)
1911
+
1912
+ def _benchmark_row(self, box, bar, status, alg, result, ok, done, total):
1913
+ box.configure(state="normal")
1914
+
1915
+ # Section banners — printed once, right before the first algorithm
1916
+ # of each category.
1917
+ if alg in AVAIL_HASH_STR and AVAIL_HASH_STR.index(alg) == 0:
1918
+ box.insert("end", "========== HASH ALGORITHMS ==========\n")
1919
+ if alg in AVAIL_ALG and AVAIL_ALG.index(alg) == 0:
1920
+ box.insert("end", "\n========== CRYPTO ALGORITHMS ==========\n")
1921
+ if alg in AVAIL_RANDOM_STR and AVAIL_RANDOM_STR.index(alg) == 0:
1922
+ box.insert("end", "\n========== RANDOM ALGORITHMS ==========\n")
1923
+
1924
+ if not ok:
1925
+ # Show exactly which algorithm the error came from, and wrap the
1926
+ # (often long) formatted CryptoError/GeneralError message with a
1927
+ # hanging indent so the table stays readable.
1928
+ wrapped = textwrap.fill(
1929
+ result,
1930
+ width=96,
1931
+ subsequent_indent=" " * 22,
1932
+ break_long_words=False)
1933
+ box.insert("end", f"{alg:<22}{wrapped}\n")
1934
+ elif alg in AVAIL_ALG:
1935
+ (enc, dec), keygen = result
1936
+ box.insert(
1937
+ "end", f"{
1938
+ enc[0]:<22}{
1939
+ enc[1]}\n{
1940
+ dec[0]:<22}{
1941
+ dec[1]}\n{
1942
+ keygen[0]:<22}{
1943
+ keygen[1]}\n")
1944
+ else:
1945
+ name, value = result
1946
+ box.insert("end", f"{name:<22}{value}\n")
1947
+
1948
+ box.see("end")
1949
+ box.configure(state="disabled")
1950
+ bar.set(done / total)
1951
+ status.configure(
1952
+ text=f"{done}/{total} complete",
1953
+ text_color=THEME["slight_gray"] if done < total else SUCCESS
1954
+ )
1955
+
1956
+ # ------------------------------------------------------------------ #
1957
+ # Secure delete / wipe free space
1958
+ # ------------------------------------------------------------------ #
1959
+
1960
+ def _build_sdelete(self):
1961
+ self._page_header(
1962
+ "trash",
1963
+ "Secure Delete",
1964
+ "Permanently remove a file with multi-pass overwrite.")
1965
+ page = self._page(row=1)
1966
+
1967
+ row = 0
1968
+ self._field_label(page, row, "File to shred")
1969
+ row += 1
1970
+ file_entry = self._entry(page, row, "File to permanently remove")
1971
+ shred_paths = []
1972
+ self._file_row(
1973
+ page,
1974
+ row,
1975
+ file_entry,
1976
+ multiple=True,
1977
+ selected=shred_paths)
1978
+ row += 1
1979
+
1980
+ self._field_label(page, row, "Overwrite pattern")
1981
+ row += 1
1982
+ method_var, _ = self._option(page, row, OVERWRITE_OPTIONS)
1983
+ row += 1
1984
+
1985
+ self._field_label(
1986
+ page, row, "Random source (used by 'random'/'gutmann')")
1987
+ row += 1
1988
+ rand_var, _ = self._option(page, row, AVAIL_RANDOM_STR)
1989
+ row += 1
1990
+
1991
+ self._field_label(page, row, "Passes")
1992
+ row += 1
1993
+ repeat_entry = self._entry(page, row, default="1")
1994
+ row += 1
1995
+
1996
+ self._field_label(page, row, "Chunk size (KB)")
1997
+ row += 1
1998
+ chunk_entry = self._entry(page, row, default="1024")
1999
+ row += 1
2000
+
2001
+ zero_var = self._checkbox(
2002
+ page, row, "Zero-out after overwriting", True)
2003
+ row += 1
2004
+ ctk.CTkLabel(
2005
+ page, text="Zero-out adds one additional overwrite pass.",
2006
+ text_color=THEME["gray_text"], font=self._font(10), anchor="w"
2007
+ ).grid(row=row, column=0, columnspan=2, sticky="w", pady=(0, 4))
2008
+ row += 1
2009
+ delete_var = self._checkbox(
2010
+ page, row, "Rename and delete the file afterwards", True)
2011
+ row += 1
2012
+
2013
+ btn, bar, status = self._run_row(page, row, "Shred File", None)
2014
+
2015
+ def do_run():
2016
+ paths = shred_paths or [
2017
+ path.strip() for path in file_entry.get().split(",") if path.strip()]
2018
+ if not paths:
2019
+ self._error_win("Missing information", "Please choose a file.")
2020
+ return
2021
+ try:
2022
+ chunksize = int(chunk_entry.get())
2023
+ repeat = int(repeat_entry.get())
2024
+ except ValueError:
2025
+ self._error_win("Invalid input",
2026
+ "Chunk size and passes must be numeric.")
2027
+ return
2028
+
2029
+ if method_var.get() == "gutmann" and repeat > 1:
2030
+ warning = CTkMessagebox(
2031
+ self,
2032
+ icon="warning",
2033
+ title="Gutmann already uses 32 passes",
2034
+ message="Gutmann performs 32 passes. Additional passes repeat the full sequence. Continue?",
2035
+ option_1="Cancel",
2036
+ option_2="Continue")
2037
+ if warning.get() != "Continue":
2038
+ return
2039
+
2040
+ confirm = CTkMessagebox(
2041
+ self,
2042
+ icon="warning",
2043
+ title="This cannot be undone",
2044
+ message=f"{
2045
+ len(paths)} selected file(s) will be overwritten and permanently deleted. Continue?",
2046
+ option_1="Cancel",
2047
+ option_2="Shred")
2048
+ if confirm.get() != "Shred":
2049
+ return
2050
+
2051
+ def work():
2052
+ return wr.secure_delete_paths(
2053
+ paths,
2054
+ method_var.get(),
2055
+ zero_var.get(),
2056
+ delete_var.get(),
2057
+ chunksize,
2058
+ rand_var.get(),
2059
+ repeat)
2060
+
2061
+ self._run_async(
2062
+ work,
2063
+ btn,
2064
+ bar,
2065
+ status,
2066
+ on_success=lambda deleted: status.configure(
2067
+ text=f"{
2068
+ len(deleted)} file(s) securely removed.",
2069
+ text_color=SUCCESS),
2070
+ start_msg="Shredding…",
2071
+ )
2072
+
2073
+ btn.configure(command=do_run)
2074
+
2075
+ def _build_wipe_space(self):
2076
+ self._page_header(
2077
+ "hard-drive",
2078
+ "Wipe Free Space",
2079
+ "Overwrite unused disk space so deleted files can't be recovered.")
2080
+ page = self._page(row=1)
2081
+
2082
+ try:
2083
+ partitions = wr.list_partitions()
2084
+ except Exception:
2085
+ partitions = []
2086
+ device_values = [p.device for p in partitions] or [
2087
+ "No partitions found"]
2088
+
2089
+ row = 0
2090
+ self._field_label(page, row, "Partition / device")
2091
+ row += 1
2092
+ device_var, _ = self._option(page, row, device_values)
2093
+ row += 1
2094
+
2095
+ self._field_label(page, row, "Fill pattern")
2096
+ row += 1
2097
+ fill_var, _ = self._option(page, row, ["Random data", "Zeros"])
2098
+ row += 1
2099
+
2100
+ self._field_label(page, row, "Random source (used by 'Random data')")
2101
+ row += 1
2102
+ rand_var, _ = self._option(page, row, AVAIL_RANDOM_STR)
2103
+ row += 1
2104
+
2105
+ self._field_label(page, row, "Chunk size (KB)")
2106
+ row += 1
2107
+ chunk_entry = self._entry(page, row, default="1024")
2108
+ row += 1
2109
+
2110
+ btn, bar, status = self._run_row(page, row, "Wipe Free Space", None)
2111
+
2112
+ def do_run():
2113
+ device = device_var.get()
2114
+ if device not in [p.device for p in partitions]:
2115
+ self._error_win(
2116
+ "No device", "No writable partition was found.")
2117
+ return
2118
+ try:
2119
+ chunksize = int(chunk_entry.get())
2120
+ except ValueError:
2121
+ self._error_win("Invalid input", "Chunk size must be numeric.")
2122
+ return
2123
+
2124
+ confirm = CTkMessagebox(
2125
+ self,
2126
+ icon="warning",
2127
+ title="This may take a while",
2128
+ message=f"This will fill all free space on '{device}' before removing the temporary file. Continue?",
2129
+ option_1="Cancel",
2130
+ option_2="Wipe")
2131
+ if confirm.get() != "Wipe":
2132
+ return
2133
+
2134
+ def work():
2135
+ wr.wipe_free_space(
2136
+ device,
2137
+ chunksize,
2138
+ fill_var.get() == "Zeros",
2139
+ rand_var.get())
2140
+ return device
2141
+
2142
+ self._run_async(
2143
+ work,
2144
+ btn,
2145
+ bar,
2146
+ status,
2147
+ on_success=lambda d: status.configure(
2148
+ text=f"Free space on '{d}' has been wiped.",
2149
+ text_color=SUCCESS),
2150
+ start_msg="Wiping free space… this can take a while.",
2151
+ )
2152
+
2153
+ btn.configure(command=do_run)
2154
+
2155
+ # ------------------------------------------------------------------ #
2156
+ # About
2157
+ # ------------------------------------------------------------------ #
2158
+
2159
+ def _build_about(self):
2160
+ wrap = ctk.CTkFrame(self.content, fg_color="transparent")
2161
+ wrap.grid(row=0, column=0, sticky="new", padx=32, pady=(36, 30))
2162
+ wrap.grid_columnconfigure(0, weight=1, minsize=0)
2163
+
2164
+ hero = ctk.CTkFrame(wrap, fg_color="transparent")
2165
+ hero.grid(row=0, column=0, sticky="ew")
2166
+ hero.grid_columnconfigure(1, weight=1)
2167
+
2168
+ try:
2169
+ icon = ctk.CTkImage(
2170
+ Image.open(ICON_PNG),
2171
+ Image.open(ICON_PNG),
2172
+ (84, 84)
2173
+ )
2174
+ ctk.CTkLabel(
2175
+ hero,
2176
+ width=84,
2177
+ image=icon,
2178
+ text="",
2179
+ anchor="nw").grid(
2180
+ row=0,
2181
+ column=0,
2182
+ rowspan=3,
2183
+ sticky="nw")
2184
+ except Exception:
2185
+ pass
2186
+
2187
+ ctk.CTkLabel(
2188
+ hero,
2189
+ text="Ghostbytes",
2190
+ text_color=THEME["content_text"],
2191
+ font=self._font(
2192
+ 30,
2193
+ "bold"),
2194
+ anchor="w").grid(
2195
+ row=0,
2196
+ column=1,
2197
+ sticky="sw",
2198
+ padx=(
2199
+ 18,
2200
+ 0))
2201
+ ctk.CTkLabel(
2202
+ hero,
2203
+ text="File Encryption Utility",
2204
+ text_color=ACCENT,
2205
+ font=self._font(
2206
+ 14,
2207
+ "bold"),
2208
+ anchor="w").grid(
2209
+ row=1,
2210
+ column=1,
2211
+ sticky="nw",
2212
+ padx=(
2213
+ 18,
2214
+ 0))
2215
+ ctk.CTkLabel(
2216
+ hero,
2217
+ text="A military-grade cryptographic toolkit for file encryption, key "
2218
+ "management, and secure data handling — built for privacy, from the "
2219
+ "ground up.",
2220
+ text_color=THEME["slight_gray"],
2221
+ font=self._font(13),
2222
+ anchor="w",
2223
+ justify="left",
2224
+ wraplength=560).grid(
2225
+ row=2,
2226
+ column=1,
2227
+ sticky="nw",
2228
+ padx=(
2229
+ 18,
2230
+ 0),
2231
+ pady=(
2232
+ 6,
2233
+ 0))
2234
+
2235
+ chips = ctk.CTkFrame(wrap, fg_color="transparent")
2236
+ chips.grid(row=1, column=0, sticky="w", pady=(20, 0))
2237
+ for i, cap in enumerate(CAPABILITIES):
2238
+ chip = ctk.CTkFrame(
2239
+ chips,
2240
+ fg_color=ACCENT_TINT,
2241
+ border_width=1,
2242
+ border_color=ACCENT_BORDER,
2243
+ corner_radius=999)
2244
+ chip.grid(
2245
+ row=0, column=i, sticky="w", padx=(
2246
+ 0 if i == 0 else 8, 0))
2247
+ ctk.CTkLabel(
2248
+ chip,
2249
+ text=cap,
2250
+ text_color=ACCENT,
2251
+ font=self._font(
2252
+ 11,
2253
+ "bold")).pack(
2254
+ padx=14,
2255
+ pady=6)
2256
+
2257
+ divider = ctk.CTkFrame(wrap, height=1, fg_color=THEME["box_border"])
2258
+ divider.grid(row=2, column=0, sticky="ew", pady=(28, 22))
2259
+
2260
+ info = ctk.CTkFrame(wrap, fg_color="transparent")
2261
+ info.grid(row=3, column=0, sticky="ew")
2262
+ info.grid_columnconfigure(2, weight=1)
2263
+
2264
+ for i, (icon_name, label, value) in enumerate(ABOUT_BOX_CONTENT):
2265
+ row_pad = (8, 4)
2266
+
2267
+ icon_badge = ctk.CTkLabel(
2268
+ info, text="", width=20, image=icon_to_ctkimage(
2269
+ icon_name, fill=ACCENT, scale_to_width=14))
2270
+ icon_badge.grid(
2271
+ row=i, column=0, sticky="w", padx=(
2272
+ 0, 10), pady=row_pad)
2273
+
2274
+ ctk.CTkLabel(
2275
+ info,
2276
+ text=label,
2277
+ text_color=THEME["slight_gray"],
2278
+ font=self._font(12),
2279
+ anchor="w",
2280
+ width=70).grid(
2281
+ row=i,
2282
+ column=1,
2283
+ sticky="w",
2284
+ pady=row_pad)
2285
+
2286
+ content = ctk.CTkLabel(
2287
+ info,
2288
+ text=value,
2289
+ text_color=THEME["content_text"],
2290
+ font=self._mono_font(12),
2291
+ justify="left",
2292
+ anchor="w")
2293
+ content.grid(
2294
+ row=i, column=2, sticky="w", padx=(
2295
+ 0, 20), pady=row_pad)
2296
+
2297
+ if label == "Source":
2298
+ content.configure(text_color=ACCENT, cursor="hand2")
2299
+ content.bind(
2300
+ "<Button-1>",
2301
+ lambda _e,
2302
+ url=value: webbrowser.open_new_tab(url))
2303
+
2304
+ # ------------------------------------------------------------------ #
2305
+ # Shared "every CryptoConfig field is editable" advanced-settings block
2306
+ # ------------------------------------------------------------------ #
2307
+
2308
+ def _build_config_fields(self, inner):
2309
+ fields = {}
2310
+ fields["hash"], hash_menu = self._option_grid(
2311
+ inner, 0, AVAIL_HASH_STR, label="Hash function")
2312
+ fields["store_iv"], _ = self._option_grid(
2313
+ inner, 1, ["append", "prepend"], label="Store IV / tag")
2314
+ fields["mac_len"] = self._entry_grid(
2315
+ inner, 2, "MAC length (4–16 bytes)", "16")
2316
+ fields["rand_func"], rand_menu = self._option_grid(
2317
+ inner, 3, AVAIL_RANDOM_STR, label="Random function")
2318
+ fields["kdf_salt"] = self._entry_grid(
2319
+ inner, 4, "KDF salt", CryptoConfig.kdf_salt.decode(
2320
+ "utf-8", "replace"), mono=True)
2321
+ fields["kdf_time"] = self._entry_grid(inner, 5, "KDF time cost", "16")
2322
+ fields["kdf_mem"] = self._entry_grid(
2323
+ inner, 6, "KDF memory (MB)", "128")
2324
+ fields["kdf_par"] = self._entry_grid(inner, 7, "KDF parallelism", "8")
2325
+ note = ctk.CTkLabel(
2326
+ inner,
2327
+ text="The KDF salt must match on every system that needs to decrypt this "
2328
+ "data — leave it default unless you control both ends.",
2329
+ text_color=THEME["gray_text"],
2330
+ font=self._font(10),
2331
+ anchor="w",
2332
+ justify="left",
2333
+ wraplength=380)
2334
+ note.grid(
2335
+ row=8, column=0, columnspan=2, sticky="w", padx=(
2336
+ 12, 12), pady=(
2337
+ 2, 10))
2338
+ fields["hash_widgets"] = [
2339
+ hash_menu, hash_menu.master.grid_slaves(
2340
+ row=0, column=0)[0]]
2341
+ fields["rand_widgets"] = [
2342
+ rand_menu, rand_menu.master.grid_slaves(
2343
+ row=3, column=0)[0]]
2344
+ fields["kdf_widgets"] = [
2345
+ fields["kdf_salt"], fields["kdf_salt"]._label_widget,
2346
+ fields["kdf_time"], fields["kdf_time"]._label_widget,
2347
+ fields["kdf_mem"], fields["kdf_mem"]._label_widget,
2348
+ fields["kdf_par"], fields["kdf_par"]._label_widget, note,
2349
+ ]
2350
+ return fields
2351
+
2352
+ def _config_from_fields(
2353
+ self,
2354
+ fields,
2355
+ allow_empty_salt=False,
2356
+ algorithm=CryptoConfig.algorithm):
2357
+ try:
2358
+ mac_len = int(fields["mac_len"].get())
2359
+ kdf_time = int(fields["kdf_time"].get())
2360
+ kdf_mem = int(fields["kdf_mem"].get())
2361
+ kdf_par = int(fields["kdf_par"].get())
2362
+ except ValueError as exc:
2363
+ raise invalid_argument(
2364
+ "advanced settings",
2365
+ "must contain numeric MAC and KDF values") from exc
2366
+
2367
+ if not 4 <= mac_len <= 16:
2368
+ raise invalid_argument(
2369
+ "MAC length", "must be between 4 and 16 bytes")
2370
+ if kdf_time < 1 or kdf_mem < 1 or kdf_par < 1:
2371
+ raise invalid_argument("KDF settings", "must all be at least 1")
2372
+
2373
+ salt = fields["kdf_salt"].get()
2374
+ if not salt:
2375
+ if not allow_empty_salt:
2376
+ raise invalid_argument("KDF salt", "cannot be empty")
2377
+ salt = CryptoConfig.kdf_salt.decode("utf-8")
2378
+ elif salt.startswith("base64:"):
2379
+ try:
2380
+ salt = base64.b64decode(salt[7:], validate=True)
2381
+ except ValueError as exc:
2382
+ raise invalid_argument(
2383
+ "KDF salt", "must contain valid base64 data") from exc
2384
+
2385
+ return wr.build_config(
2386
+ algorithm=algorithm,
2387
+ store_iv=fields["store_iv"].get(),
2388
+ mac_len=mac_len,
2389
+ kdf_salt=salt,
2390
+ kdf_time_cost=kdf_time,
2391
+ kdf_memory_cost=kdf_mem * 1024,
2392
+ kdf_parallelism=kdf_par,
2393
+ hash_func_str=fields["hash"].get(),
2394
+ rand_func=fields["rand_func"].get(),
2395
+ )
2396
+
2397
+ # ------------------------------------------------------------------ #
2398
+ # Large-RSA-key warning (session-scoped "don't remind me")
2399
+ # ------------------------------------------------------------------ #
2400
+
2401
+ def _confirm_large_rsa(self, size_bits, on_continue):
2402
+ if size_bits <= RSA_SIZE_WARNING_THRESHOLD or self._suppress_rsa_warning:
2403
+ on_continue()
2404
+ return
2405
+
2406
+ msg = CTkMessagebox(
2407
+ self, icon="warning", title="Large RSA key",
2408
+ message=(
2409
+ f"This uses a {size_bits}-bit RSA key. Generating RSA keys in Python is "
2410
+ f"inefficient at this size. For better speed and reliability, use "
2411
+ f"OpenSSL instead — e.g.\n\n"
2412
+ f" openssl genrsa -out key.pem {size_bits}\n\n"
2413
+ f"and importing the resulting file here.\n\nContinue anyway?"
2414
+ ),
2415
+ option_1="Cancel", option_2="Continue", option_3="Continue, don't ask again",
2416
+ )
2417
+ choice = msg.get()
2418
+ if choice == "Continue":
2419
+ on_continue()
2420
+ elif choice == "Continue, don't ask again":
2421
+ self._suppress_rsa_warning = True
2422
+ on_continue()
2423
+ # Cancel / closed -> do nothing
2424
+
2425
+ # ------------------------------------------------------------------ #
2426
+ # Generic form-building helpers used by every tool page
2427
+ # ------------------------------------------------------------------ #
2428
+
2429
+ def _page_header(self, icon, title, subtitle=None):
2430
+ header = ctk.CTkFrame(self.content, fg_color="transparent")
2431
+ header.grid(row=0, column=0, sticky="new", padx=32, pady=(28, 18))
2432
+ header.grid_columnconfigure(1, weight=1)
2433
+
2434
+ badge = ctk.CTkFrame(
2435
+ header,
2436
+ width=40,
2437
+ height=40,
2438
+ corner_radius=10,
2439
+ fg_color=ACCENT_TINT,
2440
+ border_width=1,
2441
+ border_color=ACCENT_BORDER)
2442
+ badge.grid(row=0, column=0, rowspan=2, sticky="nw")
2443
+ badge.grid_propagate(False)
2444
+ ctk.CTkLabel(
2445
+ badge,
2446
+ text="",
2447
+ image=icon_to_ctkimage(
2448
+ icon,
2449
+ fill=ACCENT,
2450
+ scale_to_width=18)).place(
2451
+ relx=0.5,
2452
+ rely=0.5,
2453
+ anchor="center")
2454
+
2455
+ ctk.CTkLabel(
2456
+ header, text=title, text_color=THEME["content_text"],
2457
+ font=self._font(21, "bold"), anchor="w"
2458
+ ).grid(row=0, column=1, sticky="sw", padx=(12, 0))
2459
+
2460
+ if subtitle:
2461
+ ctk.CTkLabel(
2462
+ header, text=subtitle, text_color=THEME["slight_gray"],
2463
+ font=self._font(12), anchor="w", justify="left"
2464
+ ).grid(row=1, column=1, sticky="nw", padx=(12, 0))
2465
+
2466
+ divider = ctk.CTkFrame(header, height=1, fg_color=THEME["box_border"])
2467
+ divider.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(18, 0))
2468
+
2469
+ return header
2470
+
2471
+ def _page(self, row=1):
2472
+ """A plain (unbordered) content area that page fields sit directly
2473
+ on — grouped with `_section` headings rather than boxed in a card,
2474
+ so input areas read as one continuous form."""
2475
+ frame = ctk.CTkFrame(self.content, fg_color="transparent")
2476
+ frame.grid(row=row, column=0, sticky="new", padx=32, pady=(0, 36))
2477
+ frame.grid_columnconfigure(0, weight=3)
2478
+ frame.grid_columnconfigure(1, weight=1, minsize=120)
2479
+ return frame
2480
+
2481
+ def _section(self, parent, row, title):
2482
+ wrap = ctk.CTkFrame(parent, fg_color="transparent")
2483
+ wrap.grid(row=row, column=0, columnspan=2, sticky="ew", pady=(18, 8))
2484
+ tick = ctk.CTkFrame(
2485
+ wrap,
2486
+ width=3,
2487
+ height=13,
2488
+ fg_color=ACCENT,
2489
+ corner_radius=2)
2490
+ tick.grid(row=0, column=0, sticky="w")
2491
+ ctk.CTkLabel(
2492
+ wrap,
2493
+ text=title,
2494
+ text_color=THEME["content_text"],
2495
+ font=self._font(
2496
+ 12,
2497
+ "bold"),
2498
+ anchor="w").grid(
2499
+ row=0,
2500
+ column=1,
2501
+ sticky="w",
2502
+ padx=(
2503
+ 8,
2504
+ 0))
2505
+ return wrap
2506
+
2507
+ def _field_label(self, parent, row, text):
2508
+ ctk.CTkLabel(
2509
+ parent, text=text, text_color=THEME["slight_gray"],
2510
+ font=self._font(11), anchor="w"
2511
+ ).grid(row=row, column=0, columnspan=2, sticky="w", pady=(8, 3))
2512
+
2513
+ def _entry(self, parent, row, placeholder="", show=None, default=""):
2514
+ entry = ctk.CTkEntry(
2515
+ parent, placeholder_text=placeholder, show=show,
2516
+ fg_color=THEME["box_color"], border_color=THEME["box_border"],
2517
+ text_color=THEME["content_text"]
2518
+ )
2519
+ if default:
2520
+ entry.insert(0, default)
2521
+ entry.grid(row=row, column=0, columnspan=2, sticky="ew", pady=(0, 2))
2522
+ return entry
2523
+
2524
+ def _file_row(self, parent, row, entry, save=False, dir_only=False, filetypes=(
2525
+ ("All files", "*.*"),), multiple=False, selected=None, on_browse=None):
2526
+ entry.grid_configure(columnspan=1)
2527
+
2528
+ def browse():
2529
+ if dir_only:
2530
+ path = filedialog.askdirectory()
2531
+ elif multiple:
2532
+ paths = filedialog.askopenfilenames(filetypes=filetypes)
2533
+ if paths:
2534
+ entry.delete(0, "end")
2535
+ entry.insert(0, ", ".join(paths))
2536
+ if selected is not None:
2537
+ selected.clear()
2538
+ selected.extend(paths)
2539
+ if on_browse:
2540
+ on_browse()
2541
+ return
2542
+ elif save:
2543
+ path = filedialog.asksaveasfilename(filetypes=filetypes)
2544
+ else:
2545
+ path = filedialog.askopenfilename(filetypes=filetypes)
2546
+ if path:
2547
+ entry.delete(0, "end")
2548
+ entry.insert(0, path)
2549
+ if on_browse:
2550
+ on_browse()
2551
+
2552
+ button = self._ghost_button(parent, "Browse", browse, width=80)
2553
+ button.grid(row=row, column=1, sticky="e", padx=(8, 0), pady=(0, 2))
2554
+ return button
2555
+
2556
+ def _option(self, parent, row, values, default=None, command=None):
2557
+ var = ctk.StringVar(value=default or values[0])
2558
+ menu = ctk.CTkOptionMenu(
2559
+ parent,
2560
+ values=values,
2561
+ variable=var,
2562
+ fg_color=THEME["box_color"],
2563
+ button_color=ACCENT,
2564
+ button_hover_color=ACCENT_HOVER,
2565
+ text_color=THEME["content_text"],
2566
+ dropdown_fg_color=THEME["box_color"],
2567
+ dropdown_hover_color=ACCENT_TINT,
2568
+ command=command)
2569
+ menu.grid(row=row, column=0, columnspan=2, sticky="ew", pady=(0, 2))
2570
+ return var, menu
2571
+
2572
+ def _option_grid(self, parent, row, values, label=None, default=None):
2573
+ if label:
2574
+ ctk.CTkLabel(
2575
+ parent,
2576
+ text=label,
2577
+ text_color=THEME["slight_gray"],
2578
+ font=self._font(11),
2579
+ anchor="w").grid(
2580
+ row=row,
2581
+ column=0,
2582
+ sticky="w",
2583
+ padx=(
2584
+ 12,
2585
+ 4),
2586
+ pady=4)
2587
+ var = ctk.StringVar(value=default or values[0])
2588
+ menu = ctk.CTkOptionMenu(
2589
+ parent,
2590
+ values=values,
2591
+ variable=var,
2592
+ fg_color=THEME["content_bg"],
2593
+ button_color=ACCENT,
2594
+ button_hover_color=ACCENT_HOVER,
2595
+ text_color=THEME["content_text"],
2596
+ dropdown_fg_color=THEME["content_bg"],
2597
+ dropdown_hover_color=ACCENT_TINT,
2598
+ width=170)
2599
+ col = 1 if label else 0
2600
+ span = 1 if label else 2
2601
+ menu.grid(
2602
+ row=row,
2603
+ column=col,
2604
+ columnspan=span,
2605
+ sticky="e",
2606
+ padx=(
2607
+ 4,
2608
+ 12),
2609
+ pady=4)
2610
+ return var, menu
2611
+
2612
+ def _entry_grid(self, parent, row, label, default="", mono=False):
2613
+ label_widget = ctk.CTkLabel(
2614
+ parent,
2615
+ text=label,
2616
+ text_color=THEME["slight_gray"],
2617
+ font=self._font(11),
2618
+ anchor="w")
2619
+ label_widget.grid(row=row, column=0, sticky="w", padx=(12, 4), pady=4)
2620
+ entry = ctk.CTkEntry(
2621
+ parent,
2622
+ fg_color=THEME["content_bg"],
2623
+ border_color=THEME["box_border"],
2624
+ text_color=THEME["content_text"],
2625
+ width=170,
2626
+ font=self._mono_font(11) if mono else self._font(11))
2627
+ if default:
2628
+ entry.insert(0, default)
2629
+ entry.grid(row=row, column=1, sticky="e", padx=(4, 12), pady=4)
2630
+ entry._label_widget = label_widget
2631
+ return entry
2632
+
2633
+ def _checkbox(self, parent, row, text, default=False):
2634
+ var = ctk.BooleanVar(value=default)
2635
+ ctk.CTkCheckBox(
2636
+ parent,
2637
+ text=text,
2638
+ variable=var,
2639
+ text_color=THEME["content_text"],
2640
+ fg_color=ACCENT,
2641
+ hover_color=ACCENT_HOVER,
2642
+ checkmark_color=ACCENT_ON).grid(
2643
+ row=row,
2644
+ column=0,
2645
+ columnspan=2,
2646
+ sticky="w",
2647
+ pady=4)
2648
+ return var
2649
+
2650
+ def _advanced(self, parent, row, build_fn):
2651
+ """A collapsible "Advanced settings" section — this is where every
2652
+ CryptoConfig field lives. `build_fn(inner)` populates the (initially
2653
+ hidden) tinted inner panel with its own 2-column grid."""
2654
+ wrap = ctk.CTkFrame(parent, fg_color="transparent")
2655
+ wrap.grid(row=row, column=0, columnspan=2, sticky="ew", pady=(8, 4))
2656
+ wrap.grid_columnconfigure(0, weight=1)
2657
+
2658
+ inner = ctk.CTkFrame(
2659
+ wrap,
2660
+ fg_color=ACCENT_TINT,
2661
+ border_width=1,
2662
+ border_color=ACCENT_BORDER,
2663
+ corner_radius=THEME["corner_rad"])
2664
+ inner.grid_columnconfigure(0, weight=1)
2665
+ inner.grid_columnconfigure(1, weight=1)
2666
+
2667
+ state = {"open": False}
2668
+
2669
+ def toggle():
2670
+ state["open"] = not state["open"]
2671
+ if state["open"]:
2672
+ inner.grid(row=1, column=0, sticky="ew", pady=(8, 0))
2673
+ btn.configure(
2674
+ text="▾ Advanced settings (all CryptoConfig fields)")
2675
+ else:
2676
+ inner.grid_forget()
2677
+ btn.configure(
2678
+ text="▸ Advanced settings (all CryptoConfig fields)")
2679
+
2680
+ btn = ctk.CTkButton(
2681
+ wrap,
2682
+ text="▸ Advanced settings (all CryptoConfig fields)",
2683
+ anchor="w",
2684
+ fg_color="transparent",
2685
+ hover_color=THEME["box_color"],
2686
+ text_color=ACCENT,
2687
+ font=self._font(
2688
+ 12,
2689
+ "bold"),
2690
+ command=toggle)
2691
+ btn.grid(row=0, column=0, sticky="ew")
2692
+
2693
+ build_fn(inner)
2694
+ return inner
2695
+
2696
+ def _run_row(self, parent, row, text, command):
2697
+ bar = ctk.CTkProgressBar(
2698
+ parent,
2699
+ mode="indeterminate",
2700
+ progress_color=ACCENT)
2701
+ bar.grid(row=row, column=0, columnspan=2, sticky="ew", pady=(10, 0))
2702
+ bar.grid_remove()
2703
+
2704
+ status = ctk.CTkLabel(
2705
+ parent,
2706
+ text="",
2707
+ text_color=THEME["slight_gray"],
2708
+ font=self._font(11),
2709
+ anchor="w")
2710
+ status.grid(row=row + 1, column=0, sticky="w", pady=(6, 0))
2711
+
2712
+ btn = self._primary_button(parent, text, command)
2713
+ btn.grid(row=row + 1, column=1, sticky="e", pady=(6, 0))
2714
+ return btn, bar, status
2715
+
2716
+ def _primary_button(self, parent, text, command=None, width=None):
2717
+ return ctk.CTkButton(
2718
+ parent, text=text, command=command, width=width or 140,
2719
+ fg_color=ACCENT, hover_color=ACCENT_HOVER, text_color=ACCENT_ON,
2720
+ font=self._font(13, "bold")
2721
+ )
2722
+
2723
+ def _ghost_button(self, parent, text, command=None, width=80):
2724
+ return ctk.CTkButton(
2725
+ parent, text=text, command=command, width=width,
2726
+ fg_color=THEME["box_color"], hover_color=ACCENT_TINT,
2727
+ border_width=1, border_color=THEME["box_border"],
2728
+ text_color=THEME["content_text"]
2729
+ )
2730
+
2731
+ # ------------------------------------------------------------------ #
2732
+ # Background work / error handling
2733
+ # ------------------------------------------------------------------ #
2734
+
2735
+ def _run_async(
2736
+ self,
2737
+ work,
2738
+ button,
2739
+ bar,
2740
+ status,
2741
+ on_success=None,
2742
+ start_msg="Working…"):
2743
+ """Run `work()` on a background thread so the GUI stays responsive.
2744
+ `work` must be a zero-argument callable (use a lambda/closure to bind
2745
+ arguments). Any `CryptoError`/`GeneralError`/other exception raised
2746
+ is caught and shown in an error dialog; on success `on_success` is
2747
+ called (on the main thread) with the return value of `work`.
2748
+ """
2749
+ button.configure(state="disabled")
2750
+ bar.grid()
2751
+ bar.start()
2752
+ status.configure(text=start_msg, text_color=THEME["slight_gray"])
2753
+
2754
+ def target():
2755
+ try:
2756
+ result = work()
2757
+ except Exception as e:
2758
+ # `e` is cleared once the except block ends, so capture it now
2759
+ message = str(e)
2760
+ self.after(
2761
+ 0, lambda: self._async_fail(
2762
+ button, bar, status, message))
2763
+ return
2764
+ self.after(
2765
+ 0,
2766
+ lambda: self._async_ok(
2767
+ button,
2768
+ bar,
2769
+ status,
2770
+ result,
2771
+ on_success))
2772
+
2773
+ threading.Thread(target=target, daemon=True).start()
2774
+
2775
+ def _async_fail(self, button, bar, status, message):
2776
+ bar.stop()
2777
+ bar.grid_remove()
2778
+ button.configure(state="normal")
2779
+ status.configure(text=f"Failed: {message}", text_color=DANGER)
2780
+ self._error_win("Operation Failed", message)
2781
+
2782
+ def _async_ok(self, button, bar, status, result, on_success):
2783
+ bar.stop()
2784
+ bar.grid_remove()
2785
+ button.configure(state="normal")
2786
+ status.configure(text="Done.", text_color=SUCCESS)
2787
+ if on_success:
2788
+ on_success(result)
2789
+
2790
+ def _error_win(self, title, content, _exit=False):
2791
+ msg = CTkMessagebox(
2792
+ self,
2793
+ icon="cancel",
2794
+ title=title,
2795
+ message=content,
2796
+ option_1="OK")
2797
+ if _exit and msg.get() is not None:
2798
+ sys.exit(1)
2799
+
2800
+ def _font(self, size=12, weight="normal", underline=False):
2801
+ return ctk.CTkFont(
2802
+ family=THEME["font"],
2803
+ size=size,
2804
+ weight=weight,
2805
+ underline=underline,
2806
+ )
2807
+
2808
+ def _mono_font(self, size=12, weight="normal"):
2809
+ return ctk.CTkFont(family=MONO_FONT, size=size, weight=weight)