diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index d3c7e51603..7e7ab468ff 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -5,12 +5,16 @@ from esphome.automation import Condition from esphome.const import CONF_AP, CONF_BSSID, CONF_CHANNEL, CONF_DNS1, CONF_DNS2, CONF_DOMAIN, \ CONF_FAST_CONNECT, CONF_GATEWAY, CONF_HIDDEN, CONF_ID, CONF_MANUAL_IP, CONF_NETWORKS, \ CONF_PASSWORD, CONF_POWER_SAVE_MODE, CONF_REBOOT_TIMEOUT, CONF_SSID, CONF_STATIC_IP, \ - CONF_SUBNET, CONF_USE_ADDRESS, CONF_PRIORITY + CONF_SUBNET, CONF_USE_ADDRESS, CONF_PRIORITY, CONF_IDENTITY, CONF_CERTIFICATE_AUTHORITY, \ + CONF_CERTIFICATE, CONF_KEY, CONF_USERNAME, CONF_EAP from esphome.core import CORE, HexInt, coroutine_with_priority +from . import wpa2_eap + AUTO_LOAD = ['network'] wifi_ns = cg.esphome_ns.namespace('wifi') +EAPAuth = wifi_ns.struct('EAPAuth') IPAddress = cg.global_ns.class_('IPAddress') ManualIP = wifi_ns.struct('ManualIP') WiFiComponent = wifi_ns.class_('WiFiComponent', cg.Component) @@ -56,6 +60,17 @@ STA_MANUAL_IP_SCHEMA = AP_MANUAL_IP_SCHEMA.extend({ cv.Optional(CONF_DNS2, default="0.0.0.0"): cv.ipv4, }) +EAP_AUTH_SCHEMA = cv.All(cv.only_on_esp32, cv.Schema({ + cv.Optional(CONF_IDENTITY): cv.string_strict, + cv.Optional(CONF_USERNAME): cv.string_strict, + cv.Optional(CONF_PASSWORD): cv.string_strict, + cv.Optional(CONF_CERTIFICATE_AUTHORITY): wpa2_eap.validate_certificate, + cv.Inclusive(CONF_CERTIFICATE, 'certificate_and_key'): wpa2_eap.validate_certificate, + # Only validate as file first because we need the password to load it + # Actual validation happens in validate_eap. + cv.Inclusive(CONF_KEY, 'certificate_and_key'): cv.file_, +}), wpa2_eap.validate_eap, cv.has_at_least_one_key(CONF_IDENTITY, CONF_CERTIFICATE)) + WIFI_NETWORK_BASE = cv.Schema({ cv.GenerateID(): cv.declare_id(WiFiAP), cv.Optional(CONF_SSID): cv.ssid, @@ -73,6 +88,7 @@ WIFI_NETWORK_STA = WIFI_NETWORK_BASE.extend({ cv.Optional(CONF_BSSID): cv.mac_address, cv.Optional(CONF_HIDDEN): cv.boolean, cv.Optional(CONF_PRIORITY, default=0.0): cv.float_, + cv.Optional(CONF_EAP): EAP_AUTH_SCHEMA, }) @@ -81,9 +97,13 @@ def validate(config): raise cv.Invalid("Cannot have WiFi password without SSID!") if CONF_SSID in config: + # Automatically move single network to 'networks' section + config = config.copy() network = {CONF_SSID: config.pop(CONF_SSID)} if CONF_PASSWORD in config: network[CONF_PASSWORD] = config.pop(CONF_PASSWORD) + if CONF_EAP in config: + network[CONF_EAP] = config.pop(CONF_EAP) if CONF_NETWORKS in config: raise cv.Invalid("You cannot use the 'ssid:' option together with 'networks:'. Please " "copy your network into the 'networks:' key") @@ -118,6 +138,7 @@ CONFIG_SCHEMA = cv.All(cv.Schema({ cv.Optional(CONF_SSID): cv.ssid, cv.Optional(CONF_PASSWORD): validate_password, cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, + cv.Optional(CONF_EAP): EAP_AUTH_SCHEMA, cv.Optional(CONF_AP): WIFI_NETWORK_AP, cv.Optional(CONF_DOMAIN, default='.local'): cv.domain_name, @@ -133,6 +154,29 @@ CONFIG_SCHEMA = cv.All(cv.Schema({ }), validate) +def eap_auth(config): + if config is None: + return None + ca_cert = "" + if CONF_CERTIFICATE_AUTHORITY in config: + ca_cert = wpa2_eap.read_relative_config_path(config[CONF_CERTIFICATE_AUTHORITY]) + client_cert = "" + if CONF_CERTIFICATE in config: + client_cert = wpa2_eap.read_relative_config_path(config[CONF_CERTIFICATE]) + key = "" + if CONF_KEY in config: + key = wpa2_eap.read_relative_config_path(config[CONF_KEY]) + return cg.StructInitializer( + EAPAuth, + ('identity', config.get(CONF_IDENTITY, "")), + ('username', config.get(CONF_USERNAME, "")), + ('password', config.get(CONF_PASSWORD, "")), + ('ca_cert', ca_cert), + ('client_cert', client_cert), + ('client_key', key), + ) + + def safe_ip(ip): if ip is None: return IPAddress(0, 0, 0, 0) @@ -158,6 +202,9 @@ def wifi_network(config, static_ip): cg.add(ap.set_ssid(config[CONF_SSID])) if CONF_PASSWORD in config: cg.add(ap.set_password(config[CONF_PASSWORD])) + if CONF_EAP in config: + cg.add(ap.set_eap(eap_auth(config[CONF_EAP]))) + cg.add_define('ESPHOME_WIFI_WPA2_EAP') if CONF_BSSID in config: cg.add(ap.set_bssid([HexInt(i) for i in config[CONF_BSSID].parts])) if CONF_HIDDEN in config: diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index d56e75a070..5c1c533c5d 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -541,12 +541,18 @@ void WiFiAP::set_ssid(const std::string &ssid) { this->ssid_ = ssid; } void WiFiAP::set_bssid(bssid_t bssid) { this->bssid_ = bssid; } void WiFiAP::set_bssid(optional bssid) { this->bssid_ = bssid; } void WiFiAP::set_password(const std::string &password) { this->password_ = password; } +#ifdef ESPHOME_WIFI_WPA2_EAP +void WiFiAP::set_eap(optional eap_auth) { this->eap_ = eap_auth; } +#endif void WiFiAP::set_channel(optional channel) { this->channel_ = channel; } void WiFiAP::set_manual_ip(optional manual_ip) { this->manual_ip_ = manual_ip; } void WiFiAP::set_hidden(bool hidden) { this->hidden_ = hidden; } const std::string &WiFiAP::get_ssid() const { return this->ssid_; } const optional &WiFiAP::get_bssid() const { return this->bssid_; } const std::string &WiFiAP::get_password() const { return this->password_; } +#ifdef ESPHOME_WIFI_WPA2_EAP +const optional &WiFiAP::get_eap() const { return this->eap_; } +#endif const optional &WiFiAP::get_channel() const { return this->channel_; } const optional &WiFiAP::get_manual_ip() const { return this->manual_ip_; } bool WiFiAP::get_hidden() const { return this->hidden_; } diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index d04e1c2ce0..536d914a36 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -57,6 +57,18 @@ struct ManualIP { IPAddress dns2; ///< The second DNS server. 0.0.0.0 for default. }; +#ifdef ESPHOME_WIFI_WPA2_EAP +struct EAPAuth { + std::string identity; // required for all auth types + std::string username; + std::string password; + const char *ca_cert; // optionally verify authentication server + // used for EAP-TLS + const char *client_cert; + const char *client_key; +}; +#endif // ESPHOME_WIFI_WPA2_EAP + using bssid_t = std::array; class WiFiAP { @@ -65,6 +77,9 @@ class WiFiAP { void set_bssid(bssid_t bssid); void set_bssid(optional bssid); void set_password(const std::string &password); +#ifdef ESPHOME_WIFI_WPA2_EAP + void set_eap(optional eap_auth); +#endif // ESPHOME_WIFI_WPA2_EAP void set_channel(optional channel); void set_priority(float priority) { priority_ = priority; } void set_manual_ip(optional manual_ip); @@ -72,6 +87,9 @@ class WiFiAP { const std::string &get_ssid() const; const optional &get_bssid() const; const std::string &get_password() const; +#ifdef ESPHOME_WIFI_WPA2_EAP + const optional &get_eap() const; +#endif // ESPHOME_WIFI_WPA2_EAP const optional &get_channel() const; float get_priority() const { return priority_; } const optional &get_manual_ip() const; @@ -81,6 +99,9 @@ class WiFiAP { std::string ssid_; optional bssid_; std::string password_; +#ifdef ESPHOME_WIFI_WPA2_EAP + optional eap_; +#endif // ESPHOME_WIFI_WPA2_EAP optional channel_; float priority_{0}; optional manual_ip_; diff --git a/esphome/components/wifi/wifi_component_esp32.cpp b/esphome/components/wifi/wifi_component_esp32.cpp index e345ab1671..a387be78ce 100644 --- a/esphome/components/wifi/wifi_component_esp32.cpp +++ b/esphome/components/wifi/wifi_component_esp32.cpp @@ -6,6 +6,9 @@ #include #include +#ifdef ESPHOME_WIFI_WPA2_EAP +#include +#endif #include "lwip/err.h" #include "lwip/dns.h" @@ -187,6 +190,53 @@ bool WiFiComponent::wifi_sta_connect_(WiFiAP ap) { return false; } + // setup enterprise authentication if required +#ifdef ESPHOME_WIFI_WPA2_EAP + if (ap.get_eap().has_value()) { + // note: all certificates and keys have to be null terminated. Lengths are appended by +1 to include \0. + EAPAuth eap = ap.get_eap().value(); + err = esp_wifi_sta_wpa2_ent_set_identity((uint8_t *) eap.identity.c_str(), eap.identity.length()); + if (err != ESP_OK) { + ESP_LOGV(TAG, "esp_wifi_sta_wpa2_ent_set_identity failed! %d", err); + } + int ca_cert_len = strlen(eap.ca_cert); + int client_cert_len = strlen(eap.client_cert); + int client_key_len = strlen(eap.client_key); + if (ca_cert_len) { + err = esp_wifi_sta_wpa2_ent_set_ca_cert((uint8_t *) eap.ca_cert, ca_cert_len + 1); + if (err != ESP_OK) { + ESP_LOGV(TAG, "esp_wifi_sta_wpa2_ent_set_ca_cert failed! %d", err); + } + } + // workout what type of EAP this is + // validation is not required as the config tool has already validated it + if (client_cert_len && client_key_len) { + // if we have certs, this must be EAP-TLS + err = esp_wifi_sta_wpa2_ent_set_cert_key((uint8_t *) eap.client_cert, client_cert_len + 1, + (uint8_t *) eap.client_key, client_key_len + 1, + (uint8_t *) eap.password.c_str(), strlen(eap.password.c_str())); + if (err != ESP_OK) { + ESP_LOGV(TAG, "esp_wifi_sta_wpa2_ent_set_cert_key failed! %d", err); + } + } else { + // in the absence of certs, assume this is username/password based + err = esp_wifi_sta_wpa2_ent_set_username((uint8_t *) eap.username.c_str(), eap.username.length()); + if (err != ESP_OK) { + ESP_LOGV(TAG, "esp_wifi_sta_wpa2_ent_set_username failed! %d", err); + } + err = esp_wifi_sta_wpa2_ent_set_password((uint8_t *) eap.password.c_str(), eap.password.length()); + if (err != ESP_OK) { + ESP_LOGV(TAG, "esp_wifi_sta_wpa2_ent_set_password failed! %d", err); + } + } + esp_wpa2_config_t wpa2_config = WPA2_CONFIG_INIT_DEFAULT(); + err = esp_wifi_sta_wpa2_ent_enable(&wpa2_config); + if (err != ESP_OK) { + ESP_LOGV(TAG, "esp_wifi_sta_wpa2_ent_enable failed! %d", err); + } + } +#endif // ESPHOME_WIFI_WPA2_EAP + this->wifi_apply_hostname_(); err = esp_wifi_connect(); diff --git a/esphome/components/wifi/wpa2_eap.py b/esphome/components/wifi/wpa2_eap.py new file mode 100644 index 0000000000..5b83c59866 --- /dev/null +++ b/esphome/components/wifi/wpa2_eap.py @@ -0,0 +1,138 @@ +"""Module for all WPA2 Utilities. + +The cryptography package is loaded lazily in the functions +so that it doesn't crash if it's not installed. +""" +import logging +from pathlib import Path + +from esphome.core import CORE +import esphome.config_validation as cv +from esphome.const import CONF_USERNAME, CONF_IDENTITY, CONF_PASSWORD, CONF_CERTIFICATE, \ + CONF_KEY + + +_LOGGER = logging.getLogger(__name__) + + +def validate_cryptography_installed(): + try: + import cryptography + except ImportError: + raise cv.Invalid("This settings requires the cryptography python package. " + "Please install it with `pip install cryptography`") + + if cryptography.__version__[0] < '2': + raise cv.Invalid("Please update your python cryptography installation to least 2.x " + "(pip install -U cryptography)") + + +def wrapped_load_pem_x509_certificate(value): + validate_cryptography_installed() + + from cryptography import x509 + from cryptography.hazmat.backends import default_backend + + return x509.load_pem_x509_certificate(value.encode('UTF-8'), default_backend()) + + +def wrapped_load_pem_private_key(value, password): + validate_cryptography_installed() + + from cryptography.hazmat.primitives.serialization import load_pem_private_key + from cryptography.hazmat.backends import default_backend + + if password: + password = password.encode("UTF-8") + return load_pem_private_key(value.encode('UTF-8'), password, default_backend()) + + +def read_relative_config_path(value): + return Path(CORE.relative_config_path(value)).read_text() + + +def _validate_load_certificate(value): + value = cv.file_(value) + try: + contents = read_relative_config_path(value) + return wrapped_load_pem_x509_certificate(contents) + except ValueError as err: + raise cv.Invalid(f"Invalid certificate: {err}") + + +def validate_certificate(value): + _validate_load_certificate(value) + # Validation result should be the path, not the loaded certificate + return value + + +def _validate_load_private_key(key, cert_pw): + key = cv.file_(key) + try: + contents = read_relative_config_path(key) + return wrapped_load_pem_private_key(contents, cert_pw) + except ValueError as e: + raise cv.Invalid(f"There was an error with the EAP 'password:' provided for 'key' {e}") + except TypeError as e: + raise cv.Invalid(f"There was an error with the EAP 'key:' provided: {e}") + + +def _check_private_key_cert_match(key, cert): + from cryptography.hazmat.primitives.asymmetric import rsa, ec + + def check_match_a(): + return key.public_key().public_numbers() == cert.public_key().public_numbers() + + def check_match_b(): + return key.public_key() == cert + + private_key_types = { + rsa.RSAPrivateKey: check_match_a, + ec.EllipticCurvePrivateKey: check_match_a, + } + + try: + # pylint: disable=no-name-in-module + from cryptography.hazmat.primitives.asymmetric import ed448, ed25519 + + private_key_types.update({ + ed448.Ed448PrivateKey: check_match_b, + ed25519.Ed25519PrivateKey: check_match_b, + }) + except ImportError: + # ed448, ed25519 not supported + pass + + key_type = next((kt for kt in private_key_types if isinstance(key, kt)), None) + if key_type is None: + _LOGGER.warning( + "Unrecognised EAP 'certificate:' 'key:' pair format: %s. Proceed with caution!", + type(key) + ) + elif not private_key_types[key_type](): + raise cv.Invalid("The provided EAP 'key' is not valid for the 'certificate'.") + + +def validate_eap(value): + validate_cryptography_installed() + + if CONF_USERNAME in value: + if CONF_IDENTITY not in value: + _LOGGER.info("EAP 'identity:' is not set, assuming username.") + value = value.copy() + value[CONF_IDENTITY] = value[CONF_USERNAME] + if CONF_PASSWORD not in value: + raise cv.Invalid("You cannot use the EAP 'username:' option without a 'password:'. " + "Please provide the 'password:' key") + + if CONF_CERTIFICATE in value or CONF_KEY in value: + # Check the key is valid and for this certificate, just to check the user hasn't pasted + # the wrong thing. I write this after I spent a while debugging that exact issue. + # This may require a password to decrypt to key, so we should verify that at the same time. + cert_pw = value.get(CONF_PASSWORD) + key = _validate_load_private_key(value[CONF_KEY], cert_pw) + + cert = _validate_load_certificate(value[CONF_CERTIFICATE]) + _check_private_key_cert_match(key, cert) + + return value diff --git a/esphome/const.py b/esphome/const.py index b37d816256..57694c4088 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -75,6 +75,8 @@ CONF_CALIBRATION = 'calibration' CONF_CAPACITANCE = 'capacitance' CONF_CARRIER_DUTY_PERCENT = 'carrier_duty_percent' CONF_CARRIER_FREQUENCY = 'carrier_frequency' +CONF_CERTIFICATE = "certificate" +CONF_CERTIFICATE_AUTHORITY = "certificate_authority" CONF_CHANGE_MODE_EVERY = 'change_mode_every' CONF_CHANNEL = 'channel' CONF_CHANNELS = 'channels' @@ -145,6 +147,7 @@ CONF_DRY_ACTION = 'dry_action' CONF_DRY_MODE = 'dry_mode' CONF_DUMP = 'dump' CONF_DURATION = 'duration' +CONF_EAP = 'eap' CONF_ECHO_PIN = 'echo_pin' CONF_EFFECT = 'effect' CONF_EFFECTS = 'effects' @@ -211,6 +214,7 @@ CONF_I2C = 'i2c' CONF_I2C_ID = 'i2c_id' CONF_ICON = 'icon' CONF_ID = 'id' +CONF_IDENTITY = 'identity' CONF_IDLE = 'idle' CONF_IDLE_ACTION = 'idle_action' CONF_IDLE_LEVEL = 'idle_level' @@ -238,6 +242,7 @@ CONF_JS_URL = 'js_url' CONF_JVC = 'jvc' CONF_KEEP_ON_TIME = 'keep_on_time' CONF_KEEPALIVE = 'keepalive' +CONF_KEY = 'key' CONF_LAMBDA = 'lambda' CONF_LEVEL = 'level' CONF_LG = 'lg' diff --git a/requirements_test.txt b/requirements_test.txt index 296488fc35..179f683b32 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,7 +1,8 @@ pylint==2.5.3 flake8==3.8.3 -pillow -pexpect +pillow>4.0.0 +cryptography>=2.0.0,<3 +pexpect==4.8.0 # Unit tests pytest==5.4.3