diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index d3c7e51603..8b68264b8e 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -1,3 +1,6 @@ +import logging +from cryptography.hazmat.primitives.asymmetric import rsa, ec, ed448, ed25519 + import esphome.codegen as cg import esphome.config_validation as cv from esphome import automation @@ -5,12 +8,15 @@ 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 +_LOGGER = logging.getLogger(__name__) 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) @@ -36,6 +42,55 @@ def validate_password(value): return value +def validate_eap(value): + if CONF_USERNAME in value: + if CONF_IDENTITY not in value: + _LOGGER.info("EAP 'identity:' is not set, assuming username.") + 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: + if CONF_CERTIFICATE not in value and CONF_KEY not in value: + raise cv.Invalid("You have provided an EAP 'certificate:' or 'key:' without providing " + "the other. Please check you have provided both.") + # 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. + certPw = None + if CONF_PASSWORD in value: + certPw = value[CONF_PASSWORD] + + cert = cv.load_certificate(value[CONF_CERTIFICATE]) + try: + key = cv.load_key(value[CONF_KEY], certPw) + except ValueError as e: + raise cv.Invalid( + "There was an error with the EAP 'password:' provided for 'key:' :%s" % e + ) + except TypeError as e: + raise cv.Invalid("There was an error with the EAP 'key:' provided :%s" % e) + + if isinstance(key, rsa.RSAPrivateKey): + if key.public_key().public_numbers() != cert.public_key().public_numbers(): + raise cv.Invalid("The provided EAP 'key:' does not match the 'certificate:'") + elif isinstance(key, ec.EllipticCurvePrivateKey): + if key.public_key().public_numbers() != cert.public_key().public_numbers(): + raise cv.Invalid("The provided EAP 'key:' does not match the 'certificate:'") + elif isinstance(key, ed448.Ed448PrivateKey): + if key.public_key() != cert: + raise cv.Invalid("The provided EAP 'key:' does not match the 'certificate:'") + elif isinstance(key, ed25519.Ed25519PrivateKey): + if key.public_key() != cert: + raise cv.Invalid("The provided EAP 'key:' does not match the 'certificate:'") + else: + _LOGGER.warning( + "Unrecognised EAP 'certificate:' 'key:' pair format: %s. Proceed with caution!", + type(key) + ) + return value + + def validate_channel(value): value = cv.positive_int(value) if value < 1: @@ -56,6 +111,15 @@ STA_MANUAL_IP_SCHEMA = AP_MANUAL_IP_SCHEMA.extend({ cv.Optional(CONF_DNS2, default="0.0.0.0"): cv.ipv4, }) +EAP_AUTH_SCHEMA = 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): cv.certificate, + cv.Optional(CONF_CERTIFICATE): cv.certificate, + cv.Optional(CONF_KEY): cv.string_strict, +}) + WIFI_NETWORK_BASE = cv.Schema({ cv.GenerateID(): cv.declare_id(WiFiAP), cv.Optional(CONF_SSID): cv.ssid, @@ -73,6 +137,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, }) @@ -93,6 +158,10 @@ def validate(config): raise cv.Invalid("Please specify at least an SSID or an Access Point " "to create.") + for network in config[CONF_NETWORKS]: + if CONF_EAP in network: + network[CONF_EAP] = validate_eap(network[CONF_EAP]) + if config.get(CONF_FAST_CONNECT, False): networks = config.get(CONF_NETWORKS, []) if not networks: @@ -118,6 +187,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 +203,20 @@ CONFIG_SCHEMA = cv.All(cv.Schema({ }), validate) +def eap_auth(config): + if config is None: + return None + return cg.StructInitializer( + EAPAuth, + ('identity', config.get(CONF_IDENTITY, "")), + ('username', config.get(CONF_USERNAME, "")), + ('password', config.get(CONF_PASSWORD, "")), + ('ca_cert', config.get(CONF_CERTIFICATE_AUTHORITY, "")), + ('client_cert', config.get(CONF_CERTIFICATE, "")), + ('client_key', config.get(CONF_KEY, "")), + ) + + def safe_ip(ip): if ip is None: return IPAddress(0, 0, 0, 0) @@ -158,6 +242,8 @@ 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]))) 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..dc955add25 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -541,12 +541,14 @@ 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; } +void WiFiAP::set_eap(optional eap_auth) { this->eap_ = eap_auth; } 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_; } +const optional &WiFiAP::get_eap() const { return this->eap_; } 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..d58ea6c0fa 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -57,6 +57,16 @@ struct ManualIP { IPAddress dns2; ///< The second DNS server. 0.0.0.0 for default. }; +struct EAPAuth { + std::string identity; // required for all auth types + std::string username; + std::string password; + char *ca_cert; // optionally verify authentication server + // used for EAP-TLS + char *client_cert; + char *client_key; +}; + using bssid_t = std::array; class WiFiAP { @@ -65,6 +75,7 @@ class WiFiAP { void set_bssid(bssid_t bssid); void set_bssid(optional bssid); void set_password(const std::string &password); + void set_eap(optional eap_auth); void set_channel(optional channel); void set_priority(float priority) { priority_ = priority; } void set_manual_ip(optional manual_ip); @@ -72,6 +83,7 @@ class WiFiAP { const std::string &get_ssid() const; const optional &get_bssid() const; const std::string &get_password() const; + const optional &get_eap() const; const optional &get_channel() const; float get_priority() const { return priority_; } const optional &get_manual_ip() const; @@ -81,6 +93,7 @@ class WiFiAP { std::string ssid_; optional bssid_; std::string password_; + optional 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..86c8078171 100644 --- a/esphome/components/wifi/wifi_component_esp32.cpp +++ b/esphome/components/wifi/wifi_component_esp32.cpp @@ -6,6 +6,7 @@ #include #include +#include #include "lwip/err.h" #include "lwip/dns.h" @@ -187,6 +188,51 @@ bool WiFiComponent::wifi_sta_connect_(WiFiAP ap) { return false; } + // setup enterprise authentication if required + 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 wpa2Config = WPA2_CONFIG_INIT_DEFAULT(); + err = esp_wifi_sta_wpa2_ent_enable(&wpa2Config); + if (err != ESP_OK) { + ESP_LOGV(TAG, "esp_wifi_sta_wpa2_ent_enable failed! %d", err); + } + } + this->wifi_apply_hostname_(); err = esp_wifi_connect(); diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 1319786841..9f7bdddaf0 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -10,6 +10,10 @@ from string import ascii_letters, digits import voluptuous as vol +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.serialization import load_pem_private_key + from esphome import core from esphome.const import CONF_AVAILABILITY, CONF_COMMAND_TOPIC, CONF_DISCOVERY, CONF_ID, \ CONF_INTERNAL, CONF_NAME, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, \ @@ -717,6 +721,25 @@ def domain_name(value): return value +def load_certificate(value): + return x509.load_pem_x509_certificate(value.encode('UTF-8'), default_backend()) + + +def load_key(value, password): + if password: + password = password.encode("UTF-8") + return load_pem_private_key(value.encode('UTF-8'), password, default_backend()) + + +def certificate(value): + value = string_strict(value) + try: + load_certificate(value) # raises ValueError + return value + except ValueError: + return Invalid("Invalid certificate") + + def ssid(value): value = string_strict(value) if not value: diff --git a/esphome/const.py b/esphome/const.py index 8b727b615c..cf2155b83d 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -76,6 +76,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' @@ -146,6 +148,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.txt b/requirements.txt index 0c99eaccbe..3efe16cc45 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,4 +10,5 @@ pyserial==3.4 ifaddr==0.1.6 platformio==4.3.3 esptool==2.8 +cryptography==2.9.2 click==7.1.2 diff --git a/requirements_test.txt b/requirements_test.txt index b90c4ea969..72078ad6e4 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -10,6 +10,7 @@ pyserial==3.4 ifaddr==0.1.6 platformio==4.3.3 esptool==2.8 +cryptography==2.9.2 pylint==2.5.0 flake8==3.7.9