WPA2 Enterprise Attempt 2 (#1158)

This commit is contained in:
Otto Winter 2020-07-24 15:40:05 +02:00 committed by GitHub
parent c030be4d3f
commit f3d5d27c8f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 271 additions and 3 deletions

View file

@ -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:

View file

@ -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_t> 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<EAPAuth> eap_auth) { this->eap_ = eap_auth; }
#endif
void WiFiAP::set_channel(optional<uint8_t> channel) { this->channel_ = channel; }
void WiFiAP::set_manual_ip(optional<ManualIP> 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<bssid_t> &WiFiAP::get_bssid() const { return this->bssid_; }
const std::string &WiFiAP::get_password() const { return this->password_; }
#ifdef ESPHOME_WIFI_WPA2_EAP
const optional<EAPAuth> &WiFiAP::get_eap() const { return this->eap_; }
#endif
const optional<uint8_t> &WiFiAP::get_channel() const { return this->channel_; }
const optional<ManualIP> &WiFiAP::get_manual_ip() const { return this->manual_ip_; }
bool WiFiAP::get_hidden() const { return this->hidden_; }

View file

@ -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<uint8_t, 6>;
class WiFiAP {
@ -65,6 +77,9 @@ class WiFiAP {
void set_bssid(bssid_t bssid);
void set_bssid(optional<bssid_t> bssid);
void set_password(const std::string &password);
#ifdef ESPHOME_WIFI_WPA2_EAP
void set_eap(optional<EAPAuth> eap_auth);
#endif // ESPHOME_WIFI_WPA2_EAP
void set_channel(optional<uint8_t> channel);
void set_priority(float priority) { priority_ = priority; }
void set_manual_ip(optional<ManualIP> manual_ip);
@ -72,6 +87,9 @@ class WiFiAP {
const std::string &get_ssid() const;
const optional<bssid_t> &get_bssid() const;
const std::string &get_password() const;
#ifdef ESPHOME_WIFI_WPA2_EAP
const optional<EAPAuth> &get_eap() const;
#endif // ESPHOME_WIFI_WPA2_EAP
const optional<uint8_t> &get_channel() const;
float get_priority() const { return priority_; }
const optional<ManualIP> &get_manual_ip() const;
@ -81,6 +99,9 @@ class WiFiAP {
std::string ssid_;
optional<bssid_t> bssid_;
std::string password_;
#ifdef ESPHOME_WIFI_WPA2_EAP
optional<EAPAuth> eap_;
#endif // ESPHOME_WIFI_WPA2_EAP
optional<uint8_t> channel_;
float priority_{0};
optional<ManualIP> manual_ip_;

View file

@ -6,6 +6,9 @@
#include <utility>
#include <algorithm>
#ifdef ESPHOME_WIFI_WPA2_EAP
#include <esp_wpa2.h>
#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();

View file

@ -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

View file

@ -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'

View file

@ -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