From 8844266652a409ab95d5104480d3b83f8ecaa894 Mon Sep 17 00:00:00 2001 From: Sebastian van de Meer Date: Fri, 9 Jan 2026 12:40:51 +0100 Subject: [PATCH 1/7] Add Enterprise WPA (802.1X/EAP) support - Add detection of enterprise networks in scan results - Implement EnterpriseAuthentication dialog with support for: - EAP methods: PEAP, TTLS, TLS, LEAP, FAST, PWD - Phase 2 authentication: MSCHAPV2, GTC, PAP, CHAP, MD5 - CA certificate selection - Client certificate/key for TLS - Anonymous identity support - Generate proper wpa_supplicant.conf blocks for EAP - Add certificate validation helpers - Fix security issue: wpa_supplicant.conf permissions (0o765 -> 0o600) - Add unit tests for enterprise WPA functions - Add FreeRADIUS test setup documentation Addresses: ghostbsd/networkmgr#24 --- NetworkMgr/net_api.py | 177 +++++++++++++++++- NetworkMgr/trayicon.py | 293 +++++++++++++++++++++++++++++- docs/TESTING_ENTERPRISE_WPA.md | 241 ++++++++++++++++++++++++ src/setup-nic.py | 2 +- tests/unit/test_enterprise_wpa.py | 226 +++++++++++++++++++++++ 5 files changed, 925 insertions(+), 14 deletions(-) create mode 100644 docs/TESTING_ENTERPRISE_WPA.md create mode 100644 tests/unit/test_enterprise_wpa.py diff --git a/NetworkMgr/net_api.py b/NetworkMgr/net_api.py index 78108e7..12a6f46 100755 --- a/NetworkMgr/net_api.py +++ b/NetworkMgr/net_api.py @@ -1,10 +1,21 @@ #!/usr/bin/env python from subprocess import Popen, PIPE, run, check_output +import os import re from time import sleep +# EAP methods supported for enterprise WPA +EAP_METHODS = ['PEAP', 'TTLS', 'TLS', 'LEAP', 'FAST', 'PWD'] + +# Phase 2 (inner) authentication methods +PHASE2_METHODS = ['MSCHAPV2', 'GTC', 'PAP', 'CHAP', 'MD5'] + +# Default CA certificate path on FreeBSD/GhostBSD +DEFAULT_CA_CERT = '/etc/ssl/certs/ca-root-nss.crt' + + def card_online(netcard): lan = Popen('ifconfig ' + netcard, shell=True, stdout=PIPE, universal_newlines=True) @@ -72,6 +83,70 @@ def barpercent(sn): return int((sig - noise) * 4) +def is_enterprise_network(caps_string): + """ + Detect if a network uses WPA-Enterprise (802.1X/EAP) authentication. + FreeBSD ifconfig scan shows 'WPA2-EAP' or similar for enterprise networks. + """ + enterprise_indicators = ['EAP', '802.1X', 'WPA2-EAP', 'WPA-EAP', 'RSN-EAP'] + caps_upper = caps_string.upper() + for indicator in enterprise_indicators: + if indicator in caps_upper: + return True + return False + + +def get_security_type(caps_string): + """ + Determine the security type of a wireless network. + Returns: 'OPEN', 'WEP', 'WPA-PSK', 'WPA2-PSK', 'WPA-EAP', 'WPA2-EAP' + """ + caps_upper = caps_string.upper() + if is_enterprise_network(caps_string): + if 'RSN' in caps_upper or 'WPA2' in caps_upper: + return 'WPA2-EAP' + return 'WPA-EAP' + elif 'RSN' in caps_upper: + return 'WPA2-PSK' + elif 'WPA' in caps_upper: + return 'WPA-PSK' + elif 'WEP' in caps_upper or 'PRIVACY' in caps_upper: + return 'WEP' + return 'OPEN' + + +def validate_certificate(cert_path): + """ + Validate that a certificate file exists and is readable. + Returns tuple (is_valid, error_message). + """ + if not cert_path: + return False, "No certificate path provided" + if not os.path.exists(cert_path): + return False, f"Certificate file not found: {cert_path}" + if not os.path.isfile(cert_path): + return False, f"Not a file: {cert_path}" + if not os.access(cert_path, os.R_OK): + return False, f"Certificate file not readable: {cert_path}" + return True, None + + +def get_system_ca_certificates(): + """ + Get list of available system CA certificate bundles on FreeBSD. + """ + ca_paths = [ + '/etc/ssl/certs/ca-root-nss.crt', + '/usr/local/share/certs/ca-root-nss.crt', + '/etc/ssl/cert.pem', + ] + available = [] + for path in ca_paths: + if os.path.exists(path): + available.append(path) + return available + + def network_service_state(): return False @@ -105,7 +180,12 @@ def networkdictionary(): info[3] = percentage info.insert(0, ssid) # append left over - info.append(line[83:].strip()) + caps_string = line[83:].strip() + info.append(caps_string) + # Add security type info (index 7) + info.append(get_security_type(caps_string)) + # Add enterprise flag (index 8) + info.append(is_enterprise_network(caps_string)) connectioninfo[ssid] = info if ifWlanDisable(card): connectionstat = { @@ -271,9 +351,13 @@ def delete_ssid_wpa_supplicant_config(ssid): """/etc/wpa_supplicant.conf | sed -f - /etc/wpa_supplicant.conf""" out = Popen(cmd, shell=True, stdout=PIPE, universal_newlines=True) left_over = out.stdout.read() - wpa_supplicant_conf = open('/etc/wpa_supplicant.conf', 'w') - wpa_supplicant_conf.writelines(left_over) - wpa_supplicant_conf.close() + old_umask = os.umask(0o077) + try: + with open('/etc/wpa_supplicant.conf', 'w') as wpa_supplicant_conf: + wpa_supplicant_conf.write(left_over) + os.chmod('/etc/wpa_supplicant.conf', 0o600) + finally: + os.umask(old_umask) def nic_status(card): @@ -303,3 +387,88 @@ def wait_inet(card): if re_ip and '0.0.0.0' not in re_ip.group(): print(re_ip) break + + +def generate_eap_config(ssid, eap_config): + """ + Generate wpa_supplicant network block for EAP/Enterprise authentication. + + eap_config dict should contain: + - eap_method: 'PEAP', 'TTLS', 'TLS', etc. + - identity: username + - password: password (for PEAP/TTLS) + - ca_cert: path to CA certificate (optional) + - client_cert: path to client certificate (for TLS) + - private_key: path to private key (for TLS) + - private_key_passwd: private key password (for TLS) + - phase2: inner authentication method (for PEAP/TTLS) + - anonymous_identity: anonymous outer identity (optional) + - domain_suffix_match: server domain validation (optional) + """ + eap_method = eap_config.get('eap_method', 'PEAP') + identity = eap_config.get('identity', '') + password = eap_config.get('password', '') + ca_cert = eap_config.get('ca_cert', '') + client_cert = eap_config.get('client_cert', '') + private_key = eap_config.get('private_key', '') + private_key_passwd = eap_config.get('private_key_passwd', '') + phase2 = eap_config.get('phase2', 'MSCHAPV2') + anonymous_identity = eap_config.get('anonymous_identity', '') + domain_suffix_match = eap_config.get('domain_suffix_match', '') + + ws = '\nnetwork={' + ws += f'\n\tssid="{ssid}"' + ws += '\n\tkey_mgmt=WPA-EAP' + ws += f'\n\teap={eap_method}' + ws += f'\n\tidentity="{identity}"' + + if anonymous_identity: + ws += f'\n\tanonymous_identity="{anonymous_identity}"' + + if eap_method == 'TLS': + # TLS requires client certificate + if client_cert: + ws += f'\n\tclient_cert="{client_cert}"' + if private_key: + ws += f'\n\tprivate_key="{private_key}"' + if private_key_passwd: + ws += f'\n\tprivate_key_passwd="{private_key_passwd}"' + else: + # PEAP, TTLS, etc. use password + ws += f'\n\tpassword="{password}"' + if phase2: + if eap_method == 'TTLS': + ws += f'\n\tphase2="auth={phase2}"' + else: # PEAP + ws += f'\n\tphase2="auth={phase2}"' + + if ca_cert: + ws += f'\n\tca_cert="{ca_cert}"' + + if domain_suffix_match: + ws += f'\n\tdomain_suffix_match="{domain_suffix_match}"' + + ws += '\n}\n' + return ws + + +def write_eap_config(ssid, eap_config): + """ + Write EAP configuration to wpa_supplicant.conf with secure permissions. + """ + config = generate_eap_config(ssid, eap_config) + wpa_conf_path = '/etc/wpa_supplicant.conf' + + # Write with restrictive permissions (owner read/write only) + old_umask = os.umask(0o077) + try: + with open(wpa_conf_path, 'a') as wsf: + wsf.write(config) + finally: + os.umask(old_umask) + + # Ensure file permissions are correct + try: + os.chmod(wpa_conf_path, 0o600) + except PermissionError: + pass # May need root, handled by sudoers diff --git a/NetworkMgr/trayicon.py b/NetworkMgr/trayicon.py index b0690f2..19dffd0 100755 --- a/NetworkMgr/trayicon.py +++ b/NetworkMgr/trayicon.py @@ -21,7 +21,15 @@ connectionStatus, networkdictionary, delete_ssid_wpa_supplicant_config, - nic_status + nic_status, + EAP_METHODS, + PHASE2_METHODS, + DEFAULT_CA_CERT, + is_enterprise_network, + get_security_type, + validate_certificate, + get_system_ca_certificates, + write_eap_config ) from NetworkMgr.configuration import network_card_configuration @@ -184,10 +192,16 @@ def nm_menu(self): def ssid_menu_item(self, sn, caps, ssid, ssid_info, wificard): menu_item = Gtk.ImageMenuItem(ssid) + # Check if enterprise network (index 8 contains enterprise flag) + is_enterprise = len(ssid_info) > 8 and ssid_info[8] if caps in ('E', 'ES'): is_secure = False click_action = self.menu_click_open ssid_type = ssid + elif is_enterprise: + is_secure = True + click_action = self.menu_click_enterprise + ssid_type = ssid_info else: is_secure = True click_action = self.menu_click_lock @@ -232,6 +246,13 @@ def menu_click_lock(self, widget, ssid_info, wificard): self.Authentication(ssid_info, wificard, False) self.updateinfo() + def menu_click_enterprise(self, widget, ssid_info, wificard): + if f'"{ssid_info[0]}"' in open('/etc/wpa_supplicant.conf').read(): + connectToSsid(ssid_info[0], wificard) + else: + self.EnterpriseAuthentication(ssid_info, wificard, False) + self.updateinfo() + def disconnect_wifi(self, widget, wificard): wifiDisconnection(wificard) self.updateinfo() @@ -438,7 +459,9 @@ def Authentication(self, ssid_info, card, failed): return 'Done' def setup_wpa_supplicant(self, ssid, ssid_info, pwd, card): - if 'RSN' in ssid_info[-1]: + # Determine caps string - handle extended ssid_info format + caps_string = ssid_info[-1] if len(ssid_info) <= 7 else ssid_info[6] + if 'RSN' in caps_string: # /etc/wpa_supplicant.conf written by networkmgr ws = '\nnetwork={' ws += f'\n ssid="{ssid}"' @@ -446,7 +469,7 @@ def setup_wpa_supplicant(self, ssid, ssid_info, pwd, card): ws += '\n proto=RSN' ws += f'\n psk="{pwd}"\n' ws += '}\n' - elif 'WPA' in ssid_info[-1]: + elif 'WPA' in caps_string: ws = '\nnetwork={' ws += f'\n ssid="{ssid}"' ws += '\n key_mgmt=WPA-PSK' @@ -460,14 +483,266 @@ def setup_wpa_supplicant(self, ssid, ssid_info, pwd, card): ws += '\n wep_tx_keyidx=0' ws += f'\n wep_key0={pwd}\n' ws += '}\n' - wsf = open("/etc/wpa_supplicant.conf", 'a') - wsf.writelines(ws) - wsf.close() + import os + old_umask = os.umask(0o077) + try: + with open("/etc/wpa_supplicant.conf", 'a') as wsf: + wsf.write(ws) + finally: + os.umask(old_umask) def Open_Wpa_Supplicant(self, ssid, card): ws = '\nnetwork={' ws += f'\n ssid="{ssid}"' ws += '\n key_mgmt=NONE\n}\n' - wsf = open("/etc/wpa_supplicant.conf", 'a') - wsf.writelines(ws) - wsf.close() + import os + old_umask = os.umask(0o077) + try: + with open("/etc/wpa_supplicant.conf", 'a') as wsf: + wsf.write(ws) + finally: + os.umask(old_umask) + + def add_enterprise_to_wpa_supplicant(self, widget, ssid_info, card): + """Handle enterprise authentication form submission.""" + eap_config = { + 'eap_method': self.eap_method_combo.get_active_text(), + 'identity': self.identity_entry.get_text(), + 'password': self.eap_password.get_text(), + 'phase2': self.phase2_combo.get_active_text() if hasattr(self, 'phase2_combo') else 'MSCHAPV2', + 'anonymous_identity': self.anon_identity_entry.get_text() if hasattr(self, 'anon_identity_entry') else '', + } + + # Get CA certificate path + if hasattr(self, 'ca_cert_chooser'): + ca_file = self.ca_cert_chooser.get_filename() + if ca_file: + eap_config['ca_cert'] = ca_file + + # For TLS, get client certificate and key + if eap_config['eap_method'] == 'TLS': + if hasattr(self, 'client_cert_chooser'): + client_cert = self.client_cert_chooser.get_filename() + if client_cert: + eap_config['client_cert'] = client_cert + if hasattr(self, 'private_key_chooser'): + private_key = self.private_key_chooser.get_filename() + if private_key: + eap_config['private_key'] = private_key + if hasattr(self, 'private_key_passwd'): + eap_config['private_key_passwd'] = self.private_key_passwd.get_text() + + write_eap_config(ssid_info[0], eap_config) + _thread.start_new_thread( + self.try_to_connect_to_ssid, + (ssid_info[0], ssid_info, card) + ) + self.eap_window.hide() + + def on_eap_method_changed(self, combo): + """Update dialog fields based on selected EAP method.""" + method = combo.get_active_text() + + # Show/hide TLS-specific fields + tls_mode = method == 'TLS' + if hasattr(self, 'client_cert_box'): + self.client_cert_box.set_visible(tls_mode) + if hasattr(self, 'private_key_box'): + self.private_key_box.set_visible(tls_mode) + if hasattr(self, 'private_key_passwd_box'): + self.private_key_passwd_box.set_visible(tls_mode) + + # Show/hide password field (not needed for TLS) + if hasattr(self, 'password_box'): + self.password_box.set_visible(not tls_mode) + + # Show/hide phase2 (only for PEAP/TTLS) + if hasattr(self, 'phase2_box'): + self.phase2_box.set_visible(method in ('PEAP', 'TTLS')) + + def close_eap_window(self, widget): + self.eap_window.hide() + + def on_eap_password_check(self, widget): + self.eap_password.set_visibility(widget.get_active()) + + def EnterpriseAuthentication(self, ssid_info, card, failed): + """Create authentication dialog for WPA-Enterprise networks.""" + self.eap_window = Gtk.Window() + self.eap_window.set_title(_("Enterprise Wi-Fi Authentication")) + self.eap_window.set_border_width(10) + self.eap_window.set_size_request(550, 450) + + main_box = Gtk.VBox(spacing=10) + self.eap_window.add(main_box) + + # Title + if failed: + title_text = _(f"{ssid_info[0]} Enterprise Authentication Failed") + else: + title_text = _(f"Enterprise Authentication for {ssid_info[0]}") + title_label = Gtk.Label() + title_label.set_markup(f"{title_text}") + main_box.pack_start(title_label, False, False, 5) + + # Security info + security_type = ssid_info[7] if len(ssid_info) > 7 else "WPA2-EAP" + security_label = Gtk.Label(_(f"Security: {security_type}")) + main_box.pack_start(security_label, False, False, 0) + + # Grid for form fields + grid = Gtk.Grid() + grid.set_column_spacing(10) + grid.set_row_spacing(8) + main_box.pack_start(grid, True, True, 5) + + row = 0 + + # EAP Method + eap_label = Gtk.Label(_("EAP Method:")) + eap_label.set_halign(Gtk.Align.END) + grid.attach(eap_label, 0, row, 1, 1) + self.eap_method_combo = Gtk.ComboBoxText() + for method in EAP_METHODS: + self.eap_method_combo.append_text(method) + self.eap_method_combo.set_active(0) # Default to PEAP + self.eap_method_combo.connect("changed", self.on_eap_method_changed) + grid.attach(self.eap_method_combo, 1, row, 2, 1) + row += 1 + + # Phase 2 Authentication (inner method) + self.phase2_box = Gtk.HBox(spacing=5) + phase2_label = Gtk.Label(_("Inner Auth:")) + phase2_label.set_halign(Gtk.Align.END) + grid.attach(phase2_label, 0, row, 1, 1) + self.phase2_combo = Gtk.ComboBoxText() + for method in PHASE2_METHODS: + self.phase2_combo.append_text(method) + self.phase2_combo.set_active(0) # Default to MSCHAPV2 + self.phase2_box.pack_start(self.phase2_combo, True, True, 0) + grid.attach(self.phase2_box, 1, row, 2, 1) + row += 1 + + # Identity (Username) + identity_label = Gtk.Label(_("Username:")) + identity_label.set_halign(Gtk.Align.END) + grid.attach(identity_label, 0, row, 1, 1) + self.identity_entry = Gtk.Entry() + self.identity_entry.set_hexpand(True) + grid.attach(self.identity_entry, 1, row, 2, 1) + row += 1 + + # Anonymous Identity (optional) + anon_label = Gtk.Label(_("Anonymous ID:")) + anon_label.set_halign(Gtk.Align.END) + grid.attach(anon_label, 0, row, 1, 1) + self.anon_identity_entry = Gtk.Entry() + self.anon_identity_entry.set_placeholder_text(_("Optional - for privacy")) + grid.attach(self.anon_identity_entry, 1, row, 2, 1) + row += 1 + + # Password + self.password_box = Gtk.VBox() + pwd_label = Gtk.Label(_("Password:")) + pwd_label.set_halign(Gtk.Align.END) + grid.attach(pwd_label, 0, row, 1, 1) + pwd_hbox = Gtk.HBox(spacing=5) + self.eap_password = Gtk.Entry() + self.eap_password.set_visibility(False) + self.eap_password.set_hexpand(True) + pwd_hbox.pack_start(self.eap_password, True, True, 0) + show_pwd_check = Gtk.CheckButton(_("Show")) + show_pwd_check.connect("toggled", self.on_eap_password_check) + pwd_hbox.pack_start(show_pwd_check, False, False, 0) + self.password_box.pack_start(pwd_hbox, True, True, 0) + grid.attach(self.password_box, 1, row, 2, 1) + row += 1 + + # CA Certificate + ca_label = Gtk.Label(_("CA Certificate:")) + ca_label.set_halign(Gtk.Align.END) + grid.attach(ca_label, 0, row, 1, 1) + ca_hbox = Gtk.HBox(spacing=5) + self.ca_cert_chooser = Gtk.FileChooserButton( + title=_("Select CA Certificate"), + action=Gtk.FileChooserAction.OPEN + ) + # Set default CA if available + system_cas = get_system_ca_certificates() + if system_cas: + self.ca_cert_chooser.set_filename(system_cas[0]) + ca_filter = Gtk.FileFilter() + ca_filter.set_name(_("Certificates (*.pem, *.crt, *.cer)")) + ca_filter.add_pattern("*.pem") + ca_filter.add_pattern("*.crt") + ca_filter.add_pattern("*.cer") + self.ca_cert_chooser.add_filter(ca_filter) + ca_hbox.pack_start(self.ca_cert_chooser, True, True, 0) + grid.attach(ca_hbox, 1, row, 2, 1) + row += 1 + + # TLS-specific fields (hidden by default) + # Client Certificate + self.client_cert_box = Gtk.HBox(spacing=5) + client_cert_label = Gtk.Label(_("Client Cert:")) + client_cert_label.set_halign(Gtk.Align.END) + grid.attach(client_cert_label, 0, row, 1, 1) + self.client_cert_chooser = Gtk.FileChooserButton( + title=_("Select Client Certificate"), + action=Gtk.FileChooserAction.OPEN + ) + self.client_cert_chooser.add_filter(ca_filter) + self.client_cert_box.pack_start(self.client_cert_chooser, True, True, 0) + grid.attach(self.client_cert_box, 1, row, 2, 1) + self.client_cert_box.set_visible(False) + row += 1 + + # Private Key + self.private_key_box = Gtk.HBox(spacing=5) + key_label = Gtk.Label(_("Private Key:")) + key_label.set_halign(Gtk.Align.END) + grid.attach(key_label, 0, row, 1, 1) + self.private_key_chooser = Gtk.FileChooserButton( + title=_("Select Private Key"), + action=Gtk.FileChooserAction.OPEN + ) + key_filter = Gtk.FileFilter() + key_filter.set_name(_("Key files (*.pem, *.key, *.p12)")) + key_filter.add_pattern("*.pem") + key_filter.add_pattern("*.key") + key_filter.add_pattern("*.p12") + self.private_key_chooser.add_filter(key_filter) + self.private_key_box.pack_start(self.private_key_chooser, True, True, 0) + grid.attach(self.private_key_box, 1, row, 2, 1) + self.private_key_box.set_visible(False) + row += 1 + + # Private Key Password + self.private_key_passwd_box = Gtk.HBox(spacing=5) + key_pwd_label = Gtk.Label(_("Key Password:")) + key_pwd_label.set_halign(Gtk.Align.END) + grid.attach(key_pwd_label, 0, row, 1, 1) + self.private_key_passwd = Gtk.Entry() + self.private_key_passwd.set_visibility(False) + self.private_key_passwd_box.pack_start(self.private_key_passwd, True, True, 0) + grid.attach(self.private_key_passwd_box, 1, row, 2, 1) + self.private_key_passwd_box.set_visible(False) + row += 1 + + # Buttons + button_box = Gtk.HBox(spacing=10) + button_box.set_halign(Gtk.Align.END) + main_box.pack_start(button_box, False, False, 5) + + cancel_btn = Gtk.Button(stock=Gtk.STOCK_CANCEL) + cancel_btn.connect("clicked", self.close_eap_window) + button_box.pack_start(cancel_btn, False, False, 0) + + connect_btn = Gtk.Button(stock=Gtk.STOCK_CONNECT) + connect_btn.connect("clicked", self.add_enterprise_to_wpa_supplicant, ssid_info, card) + button_box.pack_start(connect_btn, False, False, 0) + + self.eap_window.show_all() + # Trigger visibility update + self.on_eap_method_changed(self.eap_method_combo) + return 'Done' diff --git a/docs/TESTING_ENTERPRISE_WPA.md b/docs/TESTING_ENTERPRISE_WPA.md new file mode 100644 index 0000000..db7d530 --- /dev/null +++ b/docs/TESTING_ENTERPRISE_WPA.md @@ -0,0 +1,241 @@ +# Testing Enterprise WPA with FreeRADIUS + +This document describes how to set up a test environment for Enterprise WPA (WPA2-Enterprise/802.1X) on FreeBSD/GhostBSD using FreeRADIUS and hostapd. + +## Requirements + +- FreeBSD/GhostBSD system +- A wireless card that supports AP mode (for hostapd) +- Another wireless card (or separate machine) for testing as client + +## Installation + +### 1. Install FreeRADIUS + +```bash +pkg install freeradius3 +``` + +### 2. Install hostapd (if creating test AP) + +```bash +pkg install hostapd +``` + +## FreeRADIUS Configuration + +### Basic Setup + +1. Configure test users in `/usr/local/etc/raddb/users`: + +``` +# Test user for PEAP/MSCHAPV2 +testuser Cleartext-Password := "testpassword" + +# Test user with specific attributes +enterpriseuser Cleartext-Password := "enterprise123" + Reply-Message = "Welcome to Enterprise Network" +``` + +2. Configure clients (APs) in `/usr/local/etc/raddb/clients.conf`: + +``` +client localhost { + ipaddr = 127.0.0.1 + secret = testing123 +} + +client testap { + ipaddr = 192.168.1.0/24 + secret = radiussecret +} +``` + +3. Enable EAP in `/usr/local/etc/raddb/mods-enabled/eap`: + +``` +eap { + default_eap_type = peap + + tls-config tls-common { + private_key_file = /usr/local/etc/raddb/certs/server.key + certificate_file = /usr/local/etc/raddb/certs/server.pem + ca_file = /usr/local/etc/raddb/certs/ca.pem + dh_file = /usr/local/etc/raddb/certs/dh + random_file = /dev/urandom + ca_path = /usr/local/etc/raddb/certs + } + + tls { + tls = tls-common + } + + peap { + tls = tls-common + default_eap_type = mschapv2 + virtual_server = inner-tunnel + } + + ttls { + tls = tls-common + default_eap_type = mschapv2 + virtual_server = inner-tunnel + } + + mschapv2 { + } +} +``` + +### Generate Test Certificates + +Create test certificates for the RADIUS server: + +```bash +cd /usr/local/etc/raddb/certs + +# Generate CA +openssl genrsa -out ca.key 2048 +openssl req -new -x509 -days 365 -key ca.key -out ca.pem \ + -subj "/C=US/ST=Test/L=Test/O=TestOrg/CN=Test CA" + +# Generate server certificate +openssl genrsa -out server.key 2048 +openssl req -new -key server.key -out server.csr \ + -subj "/C=US/ST=Test/L=Test/O=TestOrg/CN=radius.test.local" +openssl x509 -req -days 365 -in server.csr -CA ca.pem -CAkey ca.key \ + -CAcreateserial -out server.pem + +# Generate DH parameters +openssl dhparam -out dh 2048 + +# Set permissions +chmod 640 *.key *.pem +chown root:wheel *.key *.pem +``` + +### Start FreeRADIUS + +```bash +# Test configuration +radiusd -X + +# Or start as service +service radiusd enable +service radiusd start +``` + +## hostapd Configuration (Test AP) + +Create `/usr/local/etc/hostapd.conf`: + +``` +interface=wlan0 +driver=bsd +ssid=TestEnterprise +hw_mode=g +channel=6 +auth_algs=1 +wpa=2 +wpa_key_mgmt=WPA-EAP +rsn_pairwise=CCMP + +# RADIUS server settings +ieee8021x=1 +own_ip_addr=192.168.1.1 +nas_identifier=test-ap + +auth_server_addr=127.0.0.1 +auth_server_port=1812 +auth_server_shared_secret=testing123 + +acct_server_addr=127.0.0.1 +acct_server_port=1813 +acct_server_shared_secret=testing123 +``` + +Start hostapd: + +```bash +# Create wlan interface in AP mode +ifconfig wlan0 create wlandev ath0 wlanmode hostap + +# Start hostapd +hostapd /usr/local/etc/hostapd.conf +``` + +## Testing with NetworkMgr + +1. Scan for networks - the TestEnterprise network should show as WPA2-EAP +2. Click on the network to open the Enterprise Authentication dialog +3. Enter test credentials: + - EAP Method: PEAP + - Inner Auth: MSCHAPV2 + - Username: testuser + - Password: testpassword + - CA Certificate: /usr/local/etc/raddb/certs/ca.pem (or leave empty for testing) + +## Testing with wpa_supplicant directly + +Create a test configuration `/tmp/test_eap.conf`: + +``` +network={ + ssid="TestEnterprise" + key_mgmt=WPA-EAP + eap=PEAP + identity="testuser" + password="testpassword" + phase2="auth=MSCHAPV2" + # ca_cert="/usr/local/etc/raddb/certs/ca.pem" +} +``` + +Test connection: + +```bash +wpa_supplicant -i wlan0 -c /tmp/test_eap.conf -d +``` + +## Troubleshooting + +### Check RADIUS logs + +```bash +# Run in debug mode +radiusd -X + +# Check log file +tail -f /var/log/radius.log +``` + +### Check wpa_supplicant status + +```bash +wpa_cli -i wlan0 status +wpa_cli -i wlan0 scan_results +``` + +### Common Issues + +1. **Certificate verification failed**: Use `ca_cert` parameter or temporarily disable with `phase1="peaplabel=0"` for testing only. + +2. **Authentication rejected**: Check username/password in FreeRADIUS users file. + +3. **EAP method not supported**: Ensure the EAP module is enabled in FreeRADIUS. + +## Unit Tests + +Run the enterprise WPA unit tests: + +```bash +cd /path/to/networkmgr +pytest -v tests/unit/test_enterprise_wpa.py +``` + +## Security Notes + +- The test setup uses weak secrets and self-signed certificates +- Do not use in production environments +- Always use proper CA certificates in production +- Protect wpa_supplicant.conf (should be mode 0600) diff --git a/src/setup-nic.py b/src/setup-nic.py index 47b724d..afe04f8 100755 --- a/src/setup-nic.py +++ b/src/setup-nic.py @@ -47,7 +47,7 @@ def file_content(paths): if not wpa_supplicant.exists(): wpa_supplicant.touch() shutil.chown(wpa_supplicant, user="root", group="wheel") - wpa_supplicant.chmod(0o765) + wpa_supplicant.chmod(0o600) # Secure: root-only, contains passwords for wlanNum in range(0, 9): if f'wlan{wlanNum}' not in rc_conf_content: if f'wlans_{nic}=' not in rc_conf_content: diff --git a/tests/unit/test_enterprise_wpa.py b/tests/unit/test_enterprise_wpa.py new file mode 100644 index 0000000..0667eff --- /dev/null +++ b/tests/unit/test_enterprise_wpa.py @@ -0,0 +1,226 @@ +""" +Unit tests for Enterprise WPA (802.1X/EAP) functionality. +""" +import sys +import os +import tempfile +from pathlib import Path + +import pytest + +# Add project root to path +top_dir = str(Path(__file__).absolute().parent.parent.parent) +sys.path.insert(0, top_dir) + +from NetworkMgr.net_api import ( + EAP_METHODS, + PHASE2_METHODS, + DEFAULT_CA_CERT, + is_enterprise_network, + get_security_type, + validate_certificate, + get_system_ca_certificates, + generate_eap_config, +) + + +class TestEnterpriseDetection: + """Tests for enterprise network detection.""" + + def test_is_enterprise_network_with_eap(self): + """Test detection of EAP indicator.""" + assert is_enterprise_network("RSN-EAP WPA2-EAP") is True + assert is_enterprise_network("WPA2-EAP") is True + assert is_enterprise_network("EAP") is True + + def test_is_enterprise_network_with_8021x(self): + """Test detection of 802.1X indicator.""" + assert is_enterprise_network("RSN 802.1X") is True + assert is_enterprise_network("802.1X WPA2") is True + + def test_is_enterprise_network_psk(self): + """Test that PSK networks are not detected as enterprise.""" + assert is_enterprise_network("RSN WPA2-PSK") is False + assert is_enterprise_network("WPA-PSK") is False + assert is_enterprise_network("RSN HTCAP WME") is False + + def test_is_enterprise_network_open(self): + """Test that open networks are not detected as enterprise.""" + assert is_enterprise_network("") is False + assert is_enterprise_network("ESS") is False + + def test_is_enterprise_network_case_insensitive(self): + """Test case-insensitive detection.""" + assert is_enterprise_network("wpa2-eap") is True + assert is_enterprise_network("Eap") is True + + +class TestSecurityTypeDetection: + """Tests for security type detection.""" + + def test_get_security_type_wpa2_eap(self): + """Test WPA2-EAP detection.""" + assert get_security_type("RSN WPA2-EAP") == "WPA2-EAP" + assert get_security_type("RSN EAP") == "WPA2-EAP" + + def test_get_security_type_wpa_eap(self): + """Test WPA-EAP detection.""" + assert get_security_type("WPA EAP") == "WPA-EAP" + + def test_get_security_type_wpa2_psk(self): + """Test WPA2-PSK detection.""" + assert get_security_type("RSN HTCAP WME") == "WPA2-PSK" + assert get_security_type("RSN") == "WPA2-PSK" + + def test_get_security_type_wpa_psk(self): + """Test WPA-PSK detection.""" + assert get_security_type("WPA HTCAP") == "WPA-PSK" + + def test_get_security_type_wep(self): + """Test WEP detection.""" + assert get_security_type("WEP") == "WEP" + assert get_security_type("PRIVACY") == "WEP" + + def test_get_security_type_open(self): + """Test open network detection.""" + assert get_security_type("") == "OPEN" + assert get_security_type("ESS") == "OPEN" + + +class TestCertificateValidation: + """Tests for certificate validation.""" + + def test_validate_certificate_empty_path(self): + """Test validation with empty path.""" + is_valid, error = validate_certificate("") + assert is_valid is False + assert "No certificate path" in error + + def test_validate_certificate_none_path(self): + """Test validation with None path.""" + is_valid, error = validate_certificate(None) + assert is_valid is False + + def test_validate_certificate_nonexistent(self): + """Test validation with nonexistent file.""" + is_valid, error = validate_certificate("/nonexistent/cert.pem") + assert is_valid is False + assert "not found" in error + + def test_validate_certificate_directory(self): + """Test validation with directory instead of file.""" + is_valid, error = validate_certificate("/tmp") + assert is_valid is False + assert "Not a file" in error + + def test_validate_certificate_valid_file(self): + """Test validation with valid readable file.""" + with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as f: + f.write(b"test certificate content") + temp_path = f.name + try: + is_valid, error = validate_certificate(temp_path) + assert is_valid is True + assert error is None + finally: + os.unlink(temp_path) + + +class TestEapConfigGeneration: + """Tests for EAP configuration generation.""" + + def test_generate_eap_config_peap(self): + """Test PEAP configuration generation.""" + config = { + 'eap_method': 'PEAP', + 'identity': 'testuser', + 'password': 'testpass', + 'phase2': 'MSCHAPV2', + } + result = generate_eap_config("TestNetwork", config) + + assert 'ssid="TestNetwork"' in result + assert 'key_mgmt=WPA-EAP' in result + assert 'eap=PEAP' in result + assert 'identity="testuser"' in result + assert 'password="testpass"' in result + assert 'phase2="auth=MSCHAPV2"' in result + + def test_generate_eap_config_ttls(self): + """Test TTLS configuration generation.""" + config = { + 'eap_method': 'TTLS', + 'identity': 'user@domain.com', + 'password': 'secret', + 'phase2': 'PAP', + 'ca_cert': '/etc/ssl/certs/ca.pem', + } + result = generate_eap_config("CorpWifi", config) + + assert 'eap=TTLS' in result + assert 'identity="user@domain.com"' in result + assert 'phase2="auth=PAP"' in result + assert 'ca_cert="/etc/ssl/certs/ca.pem"' in result + + def test_generate_eap_config_tls(self): + """Test TLS (certificate-based) configuration generation.""" + config = { + 'eap_method': 'TLS', + 'identity': 'client@corp.com', + 'client_cert': '/etc/ssl/client.pem', + 'private_key': '/etc/ssl/client.key', + 'private_key_passwd': 'keypass', + 'ca_cert': '/etc/ssl/ca.pem', + } + result = generate_eap_config("SecureNet", config) + + assert 'eap=TLS' in result + assert 'identity="client@corp.com"' in result + assert 'client_cert="/etc/ssl/client.pem"' in result + assert 'private_key="/etc/ssl/client.key"' in result + assert 'private_key_passwd="keypass"' in result + assert 'password=' not in result # TLS doesn't use password + + def test_generate_eap_config_with_anonymous_identity(self): + """Test configuration with anonymous identity.""" + config = { + 'eap_method': 'PEAP', + 'identity': 'realuser', + 'password': 'pass', + 'anonymous_identity': 'anonymous@domain.com', + } + result = generate_eap_config("AnonNet", config) + + assert 'anonymous_identity="anonymous@domain.com"' in result + + def test_generate_eap_config_with_domain_match(self): + """Test configuration with domain suffix match.""" + config = { + 'eap_method': 'PEAP', + 'identity': 'user', + 'password': 'pass', + 'domain_suffix_match': 'radius.company.com', + } + result = generate_eap_config("SecNet", config) + + assert 'domain_suffix_match="radius.company.com"' in result + + +class TestEapConstants: + """Tests for EAP-related constants.""" + + def test_eap_methods_defined(self): + """Test that EAP methods are defined.""" + assert 'PEAP' in EAP_METHODS + assert 'TTLS' in EAP_METHODS + assert 'TLS' in EAP_METHODS + + def test_phase2_methods_defined(self): + """Test that Phase 2 methods are defined.""" + assert 'MSCHAPV2' in PHASE2_METHODS + assert 'GTC' in PHASE2_METHODS + assert 'PAP' in PHASE2_METHODS + + def test_default_ca_cert_path(self): + """Test default CA certificate path is set.""" + assert DEFAULT_CA_CERT == '/etc/ssl/certs/ca-root-nss.crt' From 821460ae2064f46ea0c66e5b2cc8b727eac16e03 Mon Sep 17 00:00:00 2001 From: Sebastian van de Meer Date: Fri, 9 Jan 2026 14:59:13 +0100 Subject: [PATCH 2/7] Fix KeyError when WireGuard directory does not exist --- NetworkMgr/wg_api.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/NetworkMgr/wg_api.py b/NetworkMgr/wg_api.py index ea052cf..e762d1e 100644 --- a/NetworkMgr/wg_api.py +++ b/NetworkMgr/wg_api.py @@ -43,8 +43,7 @@ def wg_dictionary(): seconddictionary = { 'state': wg_state, 'info': wg_name } configs[wg_device] = seconddictionary - maindictionary['configs'] = configs - + maindictionary['configs'] = configs return maindictionary def disable_wg(wgconfig): From ab62a3a5f8b905fdcbccd50270f1abd35ffb28ba Mon Sep 17 00:00:00 2001 From: Sebastian van de Meer Date: Fri, 9 Jan 2026 15:05:44 +0100 Subject: [PATCH 3/7] Fix enterprise detection: check correct index (9) for enterprise flag --- NetworkMgr/trayicon.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NetworkMgr/trayicon.py b/NetworkMgr/trayicon.py index 19dffd0..862443c 100755 --- a/NetworkMgr/trayicon.py +++ b/NetworkMgr/trayicon.py @@ -192,8 +192,8 @@ def nm_menu(self): def ssid_menu_item(self, sn, caps, ssid, ssid_info, wificard): menu_item = Gtk.ImageMenuItem(ssid) - # Check if enterprise network (index 8 contains enterprise flag) - is_enterprise = len(ssid_info) > 8 and ssid_info[8] + # Check if enterprise network (index 9 contains enterprise flag boolean) + is_enterprise = len(ssid_info) > 9 and ssid_info[9] is True if caps in ('E', 'ES'): is_secure = False click_action = self.menu_click_open From 7174afca74a599d1f7910c79480af5b011b02675 Mon Sep 17 00:00:00 2001 From: Sebastian van de Meer Date: Fri, 9 Jan 2026 15:48:31 +0100 Subject: [PATCH 4/7] Improve enterprise network detection heuristic for FreeBSD FreeBSD ifconfig scan doesn't distinguish PSK from EAP explicitly. Use heuristic: RSN without WPS and without typical consumer router features (HTCAP, VHTCAP, ATH, WME) suggests enterprise AP. --- NetworkMgr/net_api.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/NetworkMgr/net_api.py b/NetworkMgr/net_api.py index 12a6f46..33034ae 100755 --- a/NetworkMgr/net_api.py +++ b/NetworkMgr/net_api.py @@ -86,13 +86,34 @@ def barpercent(sn): def is_enterprise_network(caps_string): """ Detect if a network uses WPA-Enterprise (802.1X/EAP) authentication. - FreeBSD ifconfig scan shows 'WPA2-EAP' or similar for enterprise networks. + + FreeBSD ifconfig scan doesn't explicitly distinguish PSK from EAP. + We use heuristics based on capability flags: + + 1. Explicit EAP indicators (if present) + 2. RSN without WPS AND without typical home router flags (HTCAP, VHTCAP, ATH) + suggests a minimal enterprise AP configuration + + Note: This is imperfect - some networks may be misdetected. """ - enterprise_indicators = ['EAP', '802.1X', 'WPA2-EAP', 'WPA-EAP', 'RSN-EAP'] caps_upper = caps_string.upper() + + # Explicit enterprise indicators (highest confidence) + enterprise_indicators = ['EAP', '802.1X', 'WPA2-EAP', 'WPA-EAP', 'RSN-EAP'] for indicator in enterprise_indicators: if indicator in caps_upper: return True + + # Heuristic: RSN without WPS and without typical consumer router features + # Enterprise APs often have minimal beacon flags + has_rsn = 'RSN' in caps_upper + has_wps = 'WPS' in caps_upper + has_consumer_features = any(f in caps_upper for f in ['HTCAP', 'VHTCAP', 'ATH', 'WME']) + + # Only flag as enterprise if: has RSN, no WPS, and no typical consumer features + if has_rsn and not has_wps and not has_consumer_features: + return True + return False From b426c084bec9f3aa54356d78b515a272f7929379 Mon Sep 17 00:00:00 2001 From: Sebastian van de Meer Date: Fri, 9 Jan 2026 16:06:14 +0100 Subject: [PATCH 5/7] Fix caps_string index bug causing PSK to be detected as WEP The setup_wpa_supplicant function was using index 6 (short CAPS like "EPS") instead of index 7 (full caps_string like "RSN HTCAP WME..."). Since "EPS" doesn't contain 'RSN' or 'WPA', networks were incorrectly falling through to WEP configuration, causing authentication failures. --- NetworkMgr/trayicon.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/NetworkMgr/trayicon.py b/NetworkMgr/trayicon.py index 862443c..d602982 100755 --- a/NetworkMgr/trayicon.py +++ b/NetworkMgr/trayicon.py @@ -460,7 +460,9 @@ def Authentication(self, ssid_info, card, failed): def setup_wpa_supplicant(self, ssid, ssid_info, pwd, card): # Determine caps string - handle extended ssid_info format - caps_string = ssid_info[-1] if len(ssid_info) <= 7 else ssid_info[6] + # Index 7 contains the full caps_string (e.g., "RSN HTCAP WME...") + # Index 6 is the short CAPS (e.g., "EPS") which doesn't contain RSN/WPA + caps_string = ssid_info[7] if len(ssid_info) > 7 else ssid_info[-1] if 'RSN' in caps_string: # /etc/wpa_supplicant.conf written by networkmgr ws = '\nnetwork={' From 00cd68fad1e5aaa5e2409be6b03e60a03a41698b Mon Sep 17 00:00:00 2001 From: Sebastian van de Meer Date: Tue, 13 Jan 2026 11:36:45 +0100 Subject: [PATCH 6/7] Address code review feedback - Add input escaping for wpa_supplicant.conf values (security) - Use context manager and error handling for config file reads - Fix i18n strings to use %s interpolation instead of f-strings - Fix test expectation for bare RSN classification --- NetworkMgr/net_api.py | 27 ++++++++++++++++++--------- NetworkMgr/trayicon.py | 14 ++++++++++---- tests/unit/test_enterprise_wpa.py | 3 ++- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/NetworkMgr/net_api.py b/NetworkMgr/net_api.py index 33034ae..24b2348 100755 --- a/NetworkMgr/net_api.py +++ b/NetworkMgr/net_api.py @@ -410,6 +410,14 @@ def wait_inet(card): break +def _escape_wpa_value(value): + """Escape special characters for wpa_supplicant.conf quoted strings.""" + if not value: + return value + # Escape backslashes first, then quotes + return value.replace('\\', '\\\\').replace('"', '\\"') + + def generate_eap_config(ssid, eap_config): """ Generate wpa_supplicant network block for EAP/Enterprise authentication. @@ -427,18 +435,19 @@ def generate_eap_config(ssid, eap_config): - domain_suffix_match: server domain validation (optional) """ eap_method = eap_config.get('eap_method', 'PEAP') - identity = eap_config.get('identity', '') - password = eap_config.get('password', '') - ca_cert = eap_config.get('ca_cert', '') - client_cert = eap_config.get('client_cert', '') - private_key = eap_config.get('private_key', '') - private_key_passwd = eap_config.get('private_key_passwd', '') + identity = _escape_wpa_value(eap_config.get('identity', '')) + password = _escape_wpa_value(eap_config.get('password', '')) + ca_cert = _escape_wpa_value(eap_config.get('ca_cert', '')) + client_cert = _escape_wpa_value(eap_config.get('client_cert', '')) + private_key = _escape_wpa_value(eap_config.get('private_key', '')) + private_key_passwd = _escape_wpa_value(eap_config.get('private_key_passwd', '')) phase2 = eap_config.get('phase2', 'MSCHAPV2') - anonymous_identity = eap_config.get('anonymous_identity', '') - domain_suffix_match = eap_config.get('domain_suffix_match', '') + anonymous_identity = _escape_wpa_value(eap_config.get('anonymous_identity', '')) + domain_suffix_match = _escape_wpa_value(eap_config.get('domain_suffix_match', '')) + ssid_escaped = _escape_wpa_value(ssid) ws = '\nnetwork={' - ws += f'\n\tssid="{ssid}"' + ws += f'\n\tssid="{ssid_escaped}"' ws += '\n\tkey_mgmt=WPA-EAP' ws += f'\n\teap={eap_method}' ws += f'\n\tidentity="{identity}"' diff --git a/NetworkMgr/trayicon.py b/NetworkMgr/trayicon.py index d602982..fea26fe 100755 --- a/NetworkMgr/trayicon.py +++ b/NetworkMgr/trayicon.py @@ -247,7 +247,13 @@ def menu_click_lock(self, widget, ssid_info, wificard): self.updateinfo() def menu_click_enterprise(self, widget, ssid_info, wificard): - if f'"{ssid_info[0]}"' in open('/etc/wpa_supplicant.conf').read(): + ssid_configured = False + try: + with open('/etc/wpa_supplicant.conf', 'r') as conf: + ssid_configured = f'"{ssid_info[0]}"' in conf.read() + except (FileNotFoundError, PermissionError, IOError): + ssid_configured = False + if ssid_configured: connectToSsid(ssid_info[0], wificard) else: self.EnterpriseAuthentication(ssid_info, wificard, False) @@ -580,16 +586,16 @@ def EnterpriseAuthentication(self, ssid_info, card, failed): # Title if failed: - title_text = _(f"{ssid_info[0]} Enterprise Authentication Failed") + title_text = _("%s Enterprise Authentication Failed") % ssid_info[0] else: - title_text = _(f"Enterprise Authentication for {ssid_info[0]}") + title_text = _("Enterprise Authentication for %s") % ssid_info[0] title_label = Gtk.Label() title_label.set_markup(f"{title_text}") main_box.pack_start(title_label, False, False, 5) # Security info security_type = ssid_info[7] if len(ssid_info) > 7 else "WPA2-EAP" - security_label = Gtk.Label(_(f"Security: {security_type}")) + security_label = Gtk.Label(_("Security: %s") % security_type) main_box.pack_start(security_label, False, False, 0) # Grid for form fields diff --git a/tests/unit/test_enterprise_wpa.py b/tests/unit/test_enterprise_wpa.py index 0667eff..acf4716 100644 --- a/tests/unit/test_enterprise_wpa.py +++ b/tests/unit/test_enterprise_wpa.py @@ -70,7 +70,8 @@ def test_get_security_type_wpa_eap(self): def test_get_security_type_wpa2_psk(self): """Test WPA2-PSK detection.""" assert get_security_type("RSN HTCAP WME") == "WPA2-PSK" - assert get_security_type("RSN") == "WPA2-PSK" + # Bare RSN without consumer features is classified as enterprise + assert get_security_type("RSN") == "WPA2-EAP" def test_get_security_type_wpa_psk(self): """Test WPA-PSK detection.""" From 616c5ec809efa80f9409caf36199ac9fb491d923 Mon Sep 17 00:00:00 2001 From: Eric Turgeon <4249848+ericbsd@users.noreply.github.com> Date: Tue, 13 Jan 2026 07:09:15 -0400 Subject: [PATCH 7/7] Update docs/TESTING_ENTERPRISE_WPA.md Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- docs/TESTING_ENTERPRISE_WPA.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/TESTING_ENTERPRISE_WPA.md b/docs/TESTING_ENTERPRISE_WPA.md index db7d530..9534080 100644 --- a/docs/TESTING_ENTERPRISE_WPA.md +++ b/docs/TESTING_ENTERPRISE_WPA.md @@ -6,7 +6,7 @@ This document describes how to set up a test environment for Enterprise WPA (WPA - FreeBSD/GhostBSD system - A wireless card that supports AP mode (for hostapd) -- Another wireless card (or separate machine) for testing as client +- Another wireless card (or separate machine) for testing as a client ## Installation