Update gui-mac.py

This commit is contained in:
Token2
2025-07-08 08:59:02 +02:00
committed by GitHub
parent 34f3525d6f
commit 92a37a3b33

View File

@@ -2,15 +2,20 @@ import os
import re import re
import subprocess import subprocess
import sys import sys
import tempfile
import tkinter as tk import tkinter as tk
from tkinter import messagebox, simpledialog, ttk from tkinter import messagebox, simpledialog, ttk
# --- Path Resolution for pyinstaller Bundle ---
def get_fido_command_path():
# --- Path Resolution for fido2-token2 Binary ---
def get_fido2_binary_path():
""" """
Determines the absolute path to the fido2-token2 binary, whether running Determines the absolute path to the fido2-token2 binary, whether running
as a script or as a bundled pyinstaller app. Enhanced for self-contained builds. as a script or as a bundled pyinstaller app.
""" """
binary_name = "fido2-token2"
if getattr(sys, 'frozen', False): if getattr(sys, 'frozen', False):
# We are running in a PyInstaller bundle # We are running in a PyInstaller bundle
if hasattr(sys, '_MEIPASS'): if hasattr(sys, '_MEIPASS'):
@@ -20,325 +25,714 @@ def get_fido_command_path():
# PyInstaller one-file bundle # PyInstaller one-file bundle
base_path = os.path.dirname(sys.executable) base_path = os.path.dirname(sys.executable)
# Print debug info for troubleshooting # Try direct path first
print(f"[DEBUG] Running in bundle mode, base_path: {base_path}") binary_path = os.path.join(base_path, binary_name)
print(f"[DEBUG] Contents of base_path: {os.listdir(base_path) if os.path.exists(base_path) else 'NOT FOUND'}")
# Primary locations to check for the fido2-token2 binary
binary_locations = [
os.path.join(base_path, "fido2-token2"),
os.path.join(base_path, "Contents", "MacOS", "fido2-token2"),
os.path.join(base_path, "Contents", "Frameworks", "fido2-token2"),
os.path.join(base_path, "MacOS", "fido2-token2"),
os.path.join(base_path, "Frameworks", "fido2-token2"),
os.path.join(os.path.dirname(base_path), "fido2-token2"),
os.path.join(os.path.dirname(base_path), "Frameworks", "fido2-token2"),
os.path.join(os.path.dirname(base_path), "MacOS", "fido2-token2")
]
for binary_path in binary_locations:
print(f"[DEBUG] Checking for binary: {binary_path}")
if os.path.exists(binary_path): if os.path.exists(binary_path):
print(f"[DEBUG] Found fido2-token2 binary at: {binary_path}")
return binary_path return binary_path
# Final fallback - return first location even if not found # Try alternative locations in the bundle
print(f"[DEBUG] Binary not found, returning default: {binary_locations[0]}") for alt_path in [
return binary_locations[0] os.path.join(base_path, "Contents", "MacOS", binary_name),
os.path.join(base_path, "Contents", "Frameworks", binary_name),
os.path.join(base_path, "MacOS", binary_name),
os.path.join(base_path, "Frameworks", binary_name),
os.path.join(os.path.dirname(base_path), binary_name),
os.path.join(os.path.dirname(base_path), "MacOS", binary_name),
os.path.join(os.path.dirname(base_path), "Frameworks", binary_name)
]:
if os.path.exists(alt_path):
return alt_path
return binary_path # Return the expected path even if not found
else: else:
# We are running in a normal Python environment for development. # We are running in a normal Python environment for development.
# Look for fido2-token2 binary in common development locations
script_dir = os.path.dirname(os.path.abspath(__file__)) script_dir = os.path.dirname(os.path.abspath(__file__))
dev_locations = [ # Try various development locations
os.path.join(script_dir, "build", "tools", "fido2-token2"), dev_paths = [
os.path.join(script_dir, "tools", "fido2-token2"), os.path.join(script_dir, "build", "staging", binary_name),
os.path.join(script_dir, "fido2-token2"), os.path.join(script_dir, "staging", binary_name),
"./build/tools/fido2-token2", os.path.join(script_dir, "build", "tools", binary_name),
"./tools/fido2-token2", os.path.join(script_dir, "tools", binary_name),
"./fido2-token2", os.path.join(script_dir, binary_name)
"fido2-token2" # System PATH lookup
] ]
for binary_path in dev_locations: for path in dev_paths:
if os.path.exists(binary_path): if os.path.exists(path):
print(f"[DEBUG] Found development binary at: {binary_path}") return path
return binary_path
# Fallback to system-wide installation # Fallback to PATH lookup
print(f"[DEBUG] Using system PATH lookup for fido2-token2") print(binary_name)
return "fido2-token2" return binary_name
print("FIDO2 Manager GUI starting...") # Define the command to execute directly
FIDO_COMMAND = get_fido_command_path() FIDO2_TOKEN_CMD = get_fido2_binary_path()
print(f"FIDO2 Command Path: {FIDO_COMMAND}")
print(f"Script exists: {os.path.exists(FIDO_COMMAND)}")
def get_device_number_from_string(device_string): # Checks the terminal emulator from which "gui.py" is executed
""" # and sets it for the subprocess commands
Extract device number from device string format "Device [1] : SoloKeys" TERM = os.environ.get("TERM", "x-terminal-emulator")
"""
import re
match = re.search(r'Device \[(\d+)\]', device_string)
if match:
return match.group(1)
return "1" # Default to device 1
# --- End of Path Resolution ---
# Global variable to store the PIN for the current session # Command below for Windows
# FIDO2_TOKEN_CMD = 'fido2-token2.exe'
# Global variables
PIN = None PIN = None
device_strings = [] # Store actual device strings for fido2-token2
# --- Core Functions ---
def get_device_list(): def get_device_list():
"""Gets the list of connected FIDO devices by calling the C binary directly.""" """Get device list directly from fido2-token2"""
global device_strings
try: try:
if not os.path.exists(FIDO_COMMAND): # Execute fido2-token2 -L to list devices
messagebox.showerror("Dependency Error", f"Required tool not found at: {FIDO_COMMAND}") result = subprocess.run([FIDO2_TOKEN_CMD, "-L"], capture_output=True, text=True)
if result.returncode != 0:
print(f"Error executing {FIDO2_TOKEN_CMD} -L: {result.stderr}")
return [] return []
# Call fido2-token2 -L directly to list devices
result = subprocess.run([FIDO_COMMAND, "-L"], capture_output=True, text=True, check=True)
# Parse the output to format like the shell script
device_list = [] device_list = []
device_strings = []
device_count = 1 device_count = 1
for line in result.stdout.strip().split("\n"): for line in result.stdout.strip().split('\n'):
if line.strip(): if line.strip() and ':' in line:
# Extract device info (look for parentheses with device type) # Handle pcsc devices specially
import re if 'pcsc://slot0:' in line:
device_string = "pcsc://slot0"
device_description = "pcsc://slot0"
else:
# Extract device string - everything before the first space or tab
parts = line.split()
if parts:
device_string = parts[0]
# For ioreg devices, ensure we don't have trailing colon
if device_string.endswith(':'):
device_string = device_string[:-1]
else:
# Fallback: split by colon and reconstruct
colon_parts = line.split(':')
if len(colon_parts) >= 2:
device_string = ':'.join(colon_parts[:2])
else:
device_string = line.strip()
# Extract device description for display
match = re.search(r'\(([^)]+)\)', line) match = re.search(r'\(([^)]+)\)', line)
if match: if match:
device_type = match.group(1) device_description = match.group(1)
device_list.append(f"Device [{device_count}] : {device_type}")
device_count += 1
else: else:
# Fallback if no parentheses found device_description = device_string
device_list.append(f"Device [{device_count}] : {line.strip()}")
device_strings.append(device_string)
device_list.append(f"Device [{device_count}] : {device_description}")
device_count += 1 device_count += 1
return device_list return device_list
except Exception as e: except Exception as e:
error_message = f"Error getting device list: {e}\n\nDetails:\n{getattr(e, 'stderr', '')}" print(f"Error executing device list command: {e}")
messagebox.showerror("Execution Error", error_message)
return [] return []
def get_device_path_by_number(device_number):
"""Get the actual device path string for a given device number.""" def get_device_string(device_digit):
"""Get the actual device string for fido2-token2 command"""
try: try:
# Get device list from fido2-token2 -L device_index = int(device_digit) - 1
result = subprocess.run([FIDO_COMMAND, "-L"], capture_output=True, text=True, check=True) if 0 <= device_index < len(device_strings):
lines = [line.strip() for line in result.stdout.split('\n') if line.strip()] return device_strings[device_index]
# Convert device_number to index (1-based to 0-based)
device_index = int(device_number) - 1
if 0 <= device_index < len(lines):
device_line = lines[device_index]
# Extract device path (everything before the first colon and space)
# Handle both "path: info" and "path:info" formats
if ':' in device_line:
# Split on ':' and take first part, but handle "pcsc://slot0:" specially
if device_line.startswith('pcsc://'):
return "pcsc://slot0"
else:
# For other formats like "/dev/hidraw0: ..."
parts = device_line.split(':', 1)
return parts[0].strip()
else:
# If no colon, return the whole line
return device_line
return None return None
except Exception as e: except (ValueError, IndexError):
print(f"[ERROR] Failed to get device path: {e}")
return None return None
def execute_info_command(device_string):
"""Fetches and displays device info in the treeview."""
tree.delete(*tree.get_children())
# Extract device number from device_string
device_number = get_device_number_from_string(device_string)
# Get the actual device path for this device number def execute_storage_command(device_digit):
device_path = get_device_path_by_number(device_number) """Execute storage command directly with fido2-token2"""
if not device_path: global PIN
messagebox.showerror("Error", f"Could not find device path for device {device_number}") device_string = get_device_string(device_digit)
if not device_string:
messagebox.showerror("Error", "Invalid device selection")
return return
print(f"[DEBUG] Using device path: {device_path}") # Build command with PIN option if provided
command = [FIDO2_TOKEN_CMD, "-I", "-c"]
if PIN and PIN != "0000":
command.extend(["-w", PIN])
command.append(device_string)
# Build PIN arguments for fido2-token2 (-w pin)
pin_args = ["-w", PIN] if PIN else []
# Commands to run with fido2-token2 directly
commands_to_run = {
"info": [FIDO_COMMAND, "-I"] + pin_args + [device_path],
"storage": [FIDO_COMMAND, "-I", "-c"] + pin_args + [device_path],
}
for key, command in commands_to_run.items():
try: try:
print(f"[DEBUG] Running command: {' '.join(command)}")
result = subprocess.run(command, capture_output=True, text=True) result = subprocess.run(command, capture_output=True, text=True)
if result.returncode != 0: if result.returncode == 0:
print(f"[DEBUG] Command {key} failed with code {result.returncode}") # Parse the output and insert into the treeview
print(f"[DEBUG] stderr: {result.stderr}") for line in reversed(result.stdout.splitlines()):
if ": " in line:
key, value = line.split(": ", 1)
tree.insert("", 0, values=(key, value))
else:
raise subprocess.CalledProcessError(result.returncode, command)
except Exception as e:
messagebox.showerror("Error", f"Command execution failed: {e}\nOutput: {result.stderr}")
def execute_info_command(device_digit):
"""Execute info command directly with fido2-token2"""
global PIN
tree.delete(*tree.get_children())
device_string = get_device_string(device_digit)
if not device_string:
messagebox.showerror("Error", "Invalid device selection")
return
# First execute storage command
storage_command = [FIDO2_TOKEN_CMD, "-I", "-c"]
if PIN and PIN != "0000":
storage_command.extend(["-w", PIN])
storage_command.append(device_string)
try:
result = subprocess.run(storage_command, capture_output=True, text=True)
# Check for specific FIDO errors
if "FIDO_ERR_PIN_INVALID" in result.stderr: if "FIDO_ERR_PIN_INVALID" in result.stderr:
messagebox.showerror("Error", "Invalid PIN provided.") messagebox.showerror("Error", "Invalid PIN provided")
return # Stop processing on bad PIN return
if "FIDO_ERR_PIN_REQUIRED" in result.stderr: if "FIDO_ERR_INVALID_ARGUMENT" in result.stderr:
messagebox.showerror("Error", "A PIN is required for this operation.") messagebox.showerror("Error", "Invalid PIN provided")
return # Stop processing return
continue # Try next command even if one fails for other reasons
# Parse output and add to tree
if "FIDO_ERR_PIN_AUTH_BLOCKED" in result.stderr:
messagebox.showerror("Error", "Wrong PIN provided too many times. Reinsert the key")
return
if "FIDO_ERR_PIN_REQUIRED" in result.stderr:
messagebox.showerror("Error",
"No PIN set for this key. Passkeys can be managed only with a PIN set. "
"You will be prompted to create a PIN on the next window")
set_pin_command = [FIDO2_TOKEN_CMD, "-S", device_string]
cmd_str = " ".join(set_pin_command)
# macOS: use AppleScript to run in Terminal
apple_script = f'''
tell application "Terminal"
activate
do script "{cmd_str}"
end tell
'''
subprocess.Popen(["osascript", "-e", apple_script])
return
if "FIDO_ERR_INVALID_CBOR" in result.stderr:
messagebox.showerror("Error",
"This is an older key (probably FIDO2.0). No passkey management is possible "
"with this key. Only basic information will be shown.")
if "FIDO_ERR_INTERNAL" in result.stderr:
messagebox.showerror("Error",
"Internal error communicating with the device. Please try unplugging and "
"replugging the device, then refresh the device list.")
return
if result.returncode == 0:
# Parse storage output
for line in result.stdout.splitlines(): for line in result.stdout.splitlines():
if ": " in line: if ": " in line:
k, v = line.split(": ", 1) key, value = line.split(": ", 1)
tree.insert("", tk.END, values=(k.strip(), v.strip())) tree.insert("", tk.END, values=(key, value))
elif line.strip(): else:
# For lines without colon, add as single value raise subprocess.CalledProcessError(result.returncode, storage_command)
tree.insert("", tk.END, values=(line.strip(), ""))
except Exception as e: except Exception as e:
messagebox.showerror("Error", f"Command '{key}' failed: {e}\nOutput: {getattr(e, 'stderr', '')}") messagebox.showerror("Error", f"Storage command execution failed: {e}\nOutput: {result.stderr}")
return
check_passkeys_button_state() # Now execute info command
check_changepin_button_state() info_command = [FIDO2_TOKEN_CMD, "-I"]
if PIN and PIN != "0000":
info_command.extend(["-w", PIN])
info_command.append(device_string)
# --- UI Event Handlers --- try:
result = subprocess.run(info_command, capture_output=True, text=True)
def set_pin_and_get_info(device_string): if result.returncode == 0:
"""Prompts for PIN and then fetches device info.""" # Parse info output
for line in result.stdout.splitlines():
if ": " in line:
key, value = line.split(": ", 1)
tree.insert("", tk.END, values=(key, value))
else:
raise subprocess.CalledProcessError(result.returncode, info_command)
except Exception as e:
messagebox.showerror("Error", f"Info command execution failed: {e}\nOutput: {result.stderr}")
def set_pin():
"""Set the PIN global variable"""
global PIN global PIN
PIN = simpledialog.askstring("PIN Code", "Enter PIN code:", show="*") PIN = simpledialog.askstring(
if PIN is not None: # Proceed even if PIN is empty, but not if cancelled "PIN Code", "Enter PIN code (enter 0000 if no PIN is set/known):", show="*"
execute_info_command(device_string) )
def on_device_selected(event): def on_device_selected(event):
"""Handles the device selection from the dropdown.""" """Handle device selection event"""
selected_text = device_var.get() global PIN
# Robust regex to find device paths on Linux, macOS, or Windows selected_device = device_var.get()
match = re.search(r"(/dev/\w+)|(pcsc://\S+)|(windows://\S+)|(ioreg://\S+)", selected_text) # Extract the digit inside the first pair of square brackets
match = re.search(r"\[(\d+)\]", selected_device)
PIN = None
set_pin()
if match: if match:
device_string = match.group(0).split(':')[0] # Get the path part before any colon device_digit = match.group(1)
set_pin_and_get_info(device_string) if PIN is not None:
execute_info_command(device_digit)
check_passkeys_button_state()
check_changepin_button_state()
else: else:
messagebox.showerror("Device Error", "Could not parse device path from selection.") messagebox.showinfo("Device Selected", "No digit found in the selected device")
def check_passkeys_button_state():
"""Check if the passkeys button should be enabled"""
passkeys_button_state = tk.DISABLED
for child in tree.get_children():
values = tree.item(child, "values")
if values and len(values) == 2 and values[0] == "existing rk(s)":
try:
rk_count = int(values[1])
if rk_count > 0:
passkeys_button_state = tk.NORMAL
break
except ValueError:
pass
passkeys_button.config(state=passkeys_button_state)
def check_changepin_button_state():
"""Check if the change PIN button should be enabled"""
change_pin_button_state = tk.DISABLED
for child in tree.get_children():
values = tree.item(child, "values")
if values and len(values) == 2 and values[0] == "remaining rk(s)":
try:
rk_count = int(values[1])
if rk_count > 0:
change_pin_button_state = tk.NORMAL
break
except ValueError:
pass
change_pin_button.config(state=change_pin_button_state)
def on_passkeys_button_click():
"""Handle passkeys button click"""
global PIN
selected_device = device_var.get()
match = re.search(r"\[(\d+)\]", selected_device)
if match:
device_digit = match.group(1)
device_string = get_device_string(device_digit)
if not device_string:
messagebox.showerror("Error", "Invalid device selection")
return
if PIN is not None:
# Execute command to get resident keys (list domains)
command = [FIDO2_TOKEN_CMD, "-L", "-r"]
if PIN and PIN != "0000":
command.extend(["-w", PIN])
command.append(device_string)
try:
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode == 0:
# Parse domains from output
domains = []
for line in result.stdout.splitlines():
match = re.search(r"= (.+)$", line)
if match:
domains.append(match.group(1))
# Execute command for each domain
cumulated_output = []
for domain in domains:
domain_command = [FIDO2_TOKEN_CMD, "-L", "-k", domain]
if PIN and PIN != "0000":
domain_command.extend(["-w", PIN])
domain_command.append(device_string)
domain_result = subprocess.run(domain_command, capture_output=True, text=True)
if domain_result.returncode == 0:
# Process the output line by line
processed_lines = []
for line in domain_result.stdout.splitlines():
if line.strip():
parts = line.split()
if len(parts) >= 3:
key_id = parts[0]
credential_id = parts[1]
user_field = " ".join(parts[2:4]) if len(parts) > 3 else parts[2]
email_field = " ".join(parts[4:6]) if len(parts) > 5 else ""
if user_field == "(null)":
user_field = ""
# Determine if user_field is an email
if "@" in user_field:
email = user_field
user = ""
else:
user = user_field
email = email_field
processed_lines.append(f"Credential ID: {credential_id}, User: {user} {email}")
cumulated_output.append(f"Domain: {domain}\n" + "\n".join(processed_lines))
else:
raise subprocess.CalledProcessError(domain_result.returncode, domain_command)
# Show cumulated output in new window
cumulated_output_str = "\n\n".join(cumulated_output)
show_output_in_new_window(cumulated_output_str, device_digit)
else:
raise subprocess.CalledProcessError(result.returncode, command)
except Exception as e:
messagebox.showerror("Error", f"Command execution failed: {e}\nOutput: {result.stderr}")
else:
messagebox.showinfo("Device Selected", "No digit found in the selected device")
def change_pin():
"""Change PIN using direct fido2-token2 command"""
selected_device = device_var.get()
match = re.search(r"\[(\d+)\]", selected_device)
if match:
device_digit = match.group(1)
device_string = get_device_string(device_digit)
if not device_string:
messagebox.showerror("Error", "Invalid device selection")
return
command = [FIDO2_TOKEN_CMD, "-C", device_string]
cmd_str = " ".join(command)
# macOS: use AppleScript to run in Terminal
apple_script = f'''
tell application "Terminal"
activate
do script "{cmd_str}"
end tell
'''
subprocess.Popen(["osascript", "-e", apple_script])
def refresh_combobox(): def refresh_combobox():
"""Refreshes the device list in the dropdown.""" """Refresh the device combobox"""
device_list = get_device_list()
if not device_list:
device_combobox['values'] = ["No FIDO devices found."]
else:
device_combobox['values'] = device_list
device_combobox.set("") device_combobox.set("")
tree.delete(*tree.get_children()) tree.delete(*tree.get_children())
passkeys_button.config(state=tk.DISABLED) passkeys_button.config(state=tk.DISABLED)
change_pin_button.config(state=tk.DISABLED) change_pin_button.config(state=tk.DISABLED)
device_list = get_device_list()
if not device_list:
print("No devices found.")
device_combobox["values"] = device_list
def show_output_in_new_window(output, device_digit):
"""Show output in a new window for passkey management"""
new_window = tk.Toplevel(root)
new_window.geometry("800x650")
new_window.title("Resident Keys / Passkeys")
# Create Treeview for displaying output
tree_new_window = ttk.Treeview(
new_window, columns=("Domain", "Credential ID", "User"), show="headings"
)
tree_new_window.heading("Domain", text="Domain")
tree_new_window.heading("Credential ID", text="Credential ID")
tree_new_window.heading("User", text="User")
tree_new_window.pack(expand=True, fill=tk.BOTH, padx=10, pady=10)
# Add scrollbars
tree_scrollbar_y = ttk.Scrollbar(new_window, orient="vertical", command=tree_new_window.yview)
tree_scrollbar_y.pack(side="right", fill="y")
tree_new_window.configure(yscrollcommand=tree_scrollbar_y.set)
tree_scrollbar_x = ttk.Scrollbar(new_window, orient="horizontal", command=tree_new_window.xview)
tree_scrollbar_x.pack(side="bottom", fill="x")
tree_new_window.configure(xscrollcommand=tree_scrollbar_x.set)
# Parse output and insert into Treeview
current_domain = ""
for line in output.splitlines():
if line.startswith("Domain: "):
current_domain = line.split("Domain: ")[1].strip()
elif "Credential ID: " in line and "User: " in line:
credential_id = line.split("Credential ID: ")[1].split(",")[0].strip()
user = line.split("User: ")[1].strip()
user = re.sub(re.escape(credential_id), "", user).strip()
tree_new_window.insert("", tk.END, values=(current_domain, credential_id, user))
def show_selected_value():
"""Delete selected passkey"""
selected_item = tree_new_window.selection()
if selected_item:
credential_id = tree_new_window.item(selected_item, "values")[1]
device_string = get_device_string(device_digit)
if not device_string:
messagebox.showerror("Error", "Invalid device selection")
return
new_window.destroy()
command = [FIDO2_TOKEN_CMD, "-D", "-i", credential_id, device_string]
cmd_str = " ".join(command)
# macOS: use AppleScript to run in Terminal
apple_script = f'''
tell application "Terminal"
activate
do script "{cmd_str}"
end tell
'''
subprocess.Popen(["osascript", "-e", apple_script])
# Create delete button
show_value_button = tk.Button(new_window, text="delete passkey", command=show_selected_value)
show_value_button.pack(pady=10)
def show_about_message(): def show_about_message():
"""Displays the about dialog.""" """Show about dialog"""
messagebox.showinfo( messagebox.showinfo(
"About", "About",
"FIDO2 Manager\n\n" "The FIDO2.1 Security Key Management Tool is a utility designed to manage and interact with FIDO2.1 security keys.\r\n"
"A utility to manage and interact with FIDO2 security keys.\n\n" "It provides functionalities to view information, manage relying parties, and perform various operations on connected FIDO2.1 devices.\r\n\r\n"
"(c) TOKEN2 Sàrl, Yubico AB" "(c)TOKEN2 Sarl\r\nVersoix, Switzerland"
)
def factory_reset():
# Step 1: Confirm factory reset
confirm_reset = messagebox.askyesno(
"Confirm Factory Reset",
"Are you sure you want to factory reset?"
)
if not confirm_reset:
return
# Step 2: Ask to unplug and replug the key
replug_key = messagebox.askyesno(
"Replug Key",
"Please unplug the key, plug it back in, then click Yes to continue. Important - the reset command can be completed only within 10 seconds after plugging the key"
)
if not replug_key:
return
# Step 3: Tell user to touch key when it starts blinking
messagebox.showinfo(
"Touch Key",
"When the key starts blinking, touch the sensor to complete reset."
) )
def check_passkeys_button_state():
"""Enables 'Passkeys' button if resident keys exist."""
state = tk.DISABLED
for child in tree.get_children():
values = tree.item(child, "values")
if values and len(values) > 1 and values[0] == "existing rk(s)":
try: try:
if int(values[1]) > 0: # Run -L to list the device
state = tk.NORMAL list_command = [FIDO2_TOKEN_CMD, "-L"]
break result = subprocess.run(list_command, capture_output=True, text=True, check=True)
except (ValueError, IndexError): output = result.stdout.strip()
pass
passkeys_button.config(state=state)
def check_changepin_button_state(): # Extract string before first colon (e.g., ioreg://4302783856)
"""Enables 'Change PIN' button if PIN is set.""" match = re.search(r"(ioreg://\d+):", output)
state = tk.DISABLED if not match:
for child in tree.get_children(): messagebox.showerror("Error", "No valid device found in output")
values = tree.item(child, "values") return
if values and len(values) > 1 and values[0] == "pin retries":
state = tk.NORMAL
break
change_pin_button.config(state=state)
# Placeholder functions for buttons device_string = match.group(1)
def on_passkeys_button_click():
messagebox.showinfo("Not Implemented", "Passkey management functionality is not yet implemented.")
def change_pin(): # Run -R with extracted device string
messagebox.showinfo("Not Implemented", "Change PIN functionality is not yet implemented.") reset_command = [FIDO2_TOKEN_CMD, "-R", device_string]
subprocess.run(reset_command, check=True)
# --- GUI Layout --- messagebox.showinfo("Success", "Factory reset command sent successfully.")
except subprocess.CalledProcessError as e:
messagebox.showerror("Error", f"Command failed:\n{e.stderr}")
except Exception as e:
messagebox.showerror("Error", f"Failed to reset: {e}")
def terminal_path():
bash_script = """#!/bin/bash
# CLI installer for FIDO2 Manager
set -e
APP_PATH="/Applications/fido2-manage.app"
CLI_SCRIPT="$APP_PATH/Contents/MacOS/fido2-manage-mac.sh"
SYMLINK_TARGET="/usr/local/bin/fido2-manage"
echo "=== FIDO2 Manager CLI Installer ==="
echo ""
if [[ ! -f "$CLI_SCRIPT" ]]; then
echo "❌ FIDO2 Manager app not found at: $APP_PATH"
echo "Please install the app first by dragging it to Applications folder."
exit 1
fi
echo "✅ Found FIDO2 Manager app"
if [[ -L "$SYMLINK_TARGET" ]]; then
existing_target=$(readlink "$SYMLINK_TARGET")
if [[ "$existing_target" == "$CLI_SCRIPT" ]]; then
echo "✅ CLI already installed - fido2-manage command is available"
echo ""
echo "Test it: fido2-manage -help"
exit 0
else
echo "⚠️ Existing fido2-manage command found pointing to: $existing_target"
echo "Do you want to replace it? (y/N)"
read -r response
if [[ ! "$response" =~ ^[Yy]$ ]]; then
echo "Installation cancelled."
exit 1
fi
sudo rm -f "$SYMLINK_TARGET"
fi
elif [[ -f "$SYMLINK_TARGET" ]]; then
echo "⚠️ Existing fido2-manage file found (not a symlink)"
echo "Do you want to replace it? (y/N)"
read -r response
if [[ ! "$response" =~ ^[Yy]$ ]]; then
echo "Installation cancelled."
exit 1
fi
sudo rm -f "$SYMLINK_TARGET"
fi
echo "Creating symlink: $SYMLINK_TARGET -> $CLI_SCRIPT"
if sudo ln -sf "$CLI_SCRIPT" "$SYMLINK_TARGET"; then
echo "✅ CLI installed successfully!"
echo ""
echo "You can now use: fido2-manage -help"
echo "Examples:"
echo " fido2-manage -list"
echo " fido2-manage -info -device 1"
echo " fido2-manage -storage -device 1"
echo ""
else
echo "❌ Failed to create symlink. You may need administrator privileges."
exit 1
fi
echo ""
read -p "Press Enter to close this window..."
"""
# Save script to a temp file
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.sh') as f:
f.write(bash_script)
script_path = f.name
os.chmod(script_path, 0o755)
# Escape the path for AppleScript
escaped_path = script_path.replace(" ", "\\ ")
# AppleScript to run in Terminal
applescript = f'''
tell application "Terminal"
activate
do script "{escaped_path}"
end tell
'''
subprocess.run(["osascript", "-e", applescript])
# Create main application window
root = tk.Tk() root = tk.Tk()
root.title("FIDO2 Manager")
root.geometry("800x600") root.geometry("800x600")
root.minsize(700, 400) root.title("FIDO2.1 Manager - Python GUI 0.2 - (c) Token2 ")
# Main container # Create top frame for controls
main_frame = ttk.Frame(root, padding="10") top_frame = ttk.Frame(root)
main_frame.pack(expand=True, fill="both") top_frame.pack(side=tk.TOP, fill=tk.X)
# --- Top Frame: Device Selection & Refresh --- # Create label for dropdown
top_frame = ttk.Frame(main_frame) label = tk.Label(top_frame, text="Select Device:")
top_frame.pack(side="top", fill="x", pady=(0, 10)) label.pack(side=tk.LEFT, padx=10, pady=10)
ttk.Label(top_frame, text="Select Device:").pack(side="left") # Create ComboBox and populate with device list
device_list = get_device_list()
if not device_list:
device_list = ["No devices found."]
device_var = tk.StringVar() device_var = tk.StringVar()
device_combobox = ttk.Combobox(top_frame, textvariable=device_var, state="readonly") device_combobox = ttk.Combobox(top_frame, textvariable=device_var, values=device_list, width=60)
device_combobox.pack(side="left", expand=True, fill="x", padx=5) device_combobox.pack(side=tk.LEFT, padx=10, pady=10)
device_combobox.bind("<<ComboboxSelected>>", on_device_selected) device_combobox.bind("<<ComboboxSelected>>", on_device_selected)
refresh_button = ttk.Button(top_frame, text="Refresh", command=refresh_combobox) # Create refresh button
refresh_button.pack(side="left") refresh_button = tk.Button(top_frame, text="Refresh", command=refresh_combobox)
refresh_button.pack(side=tk.LEFT, padx=10, pady=10)
# --- Center Frame: Information Treeview --- # Create Treeview for displaying output
tree_frame = ttk.Frame(main_frame) tree_frame = ttk.Frame(root)
tree_frame.pack(expand=True, fill="both") tree_frame.pack(expand=True, fill=tk.BOTH, padx=10, pady=10)
tree_scrollbar_y = ttk.Scrollbar(tree_frame, orient="vertical")
tree_scrollbar_x = ttk.Scrollbar(tree_frame, orient="horizontal")
tree = ttk.Treeview(
tree_frame,
columns=("Key", "Value"),
show="headings",
yscrollcommand=tree_scrollbar_y.set,
xscrollcommand=tree_scrollbar_x.set,
)
tree_scrollbar_y.config(command=tree.yview)
tree_scrollbar_x.config(command=tree.xview)
tree_scrollbar_y.pack(side="right", fill="y")
tree_scrollbar_x.pack(side="bottom", fill="x")
tree = ttk.Treeview(tree_frame, columns=("Key", "Value"), show="headings")
tree.heading("Key", text="Key") tree.heading("Key", text="Key")
tree.heading("Value", text="Value") tree.heading("Value", text="Value")
tree.column("Key", width=200, stretch=tk.NO, anchor="w") tree.pack(expand=True, fill=tk.BOTH)
tree.column("Value", width=550, anchor="w")
# Scrollbars # Create buttons
vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=tree.yview) passkeys_button = ttk.Button(root, text="Passkeys", state=tk.DISABLED, command=on_passkeys_button_click)
hsb = ttk.Scrollbar(tree_frame, orient="horizontal", command=tree.xview) passkeys_button.pack(side=tk.LEFT, padx=5, pady=10)
tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
vsb.pack(side="right", fill="y") change_pin_button = ttk.Button(root, text="Change PIN", state=tk.DISABLED, command=change_pin)
hsb.pack(side="bottom", fill="x") change_pin_button.pack(side=tk.LEFT, padx=5, pady=10)
tree.pack(side="left", expand=True, fill="both")
# --- Bottom Frame: Action Buttons --- #factory_reset_button = tk.Button(root, text="Factory Reset", command=factory_reset )
bottom_frame = ttk.Frame(main_frame) #factory_reset_button.pack(side=tk.LEFT, padx=10, pady=10)
bottom_frame.pack(side="bottom", fill="x", pady=(10, 0))
passkeys_button = ttk.Button(bottom_frame, text="Passkeys", state=tk.DISABLED, command=on_passkeys_button_click) terminal_path = tk.Button(root, text="fido2-manage CLI", command=terminal_path )
passkeys_button.pack(side="left") terminal_path.pack(side=tk.LEFT, padx=15, pady=10)
change_pin_button = ttk.Button(bottom_frame, text="Change PIN", state=tk.DISABLED, command=change_pin) about_button = ttk.Button(root, text="About", command=show_about_message)
change_pin_button.pack(side="left", padx=5) about_button.pack(side=tk.RIGHT, padx=5, pady=10)
about_button = ttk.Button(bottom_frame, text="About", command=show_about_message) # Run the main loop
about_button.pack(side="right")
# --- Initial Load ---
refresh_combobox()
root.mainloop() root.mainloop()