From 747688c4ccf5bfbf7424731fb4abce05525cc1e3 Mon Sep 17 00:00:00 2001 From: iranl Date: Thu, 13 Feb 2025 20:28:17 +0100 Subject: [PATCH 1/3] Update pio and esp-nimble-cpp --- README.md | 2 +- platformio.ini | 2 +- src/idf_component.yml | 2 +- updater/platformio.ini | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 020dcb6..4ba25b1 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Feel free to join us on Discord: https://discord.gg/9nPq85bP4p ## Supported devices Supported ESP32 devices: -- Nuki Hub is compiled against all ESP32 models with Wi-Fi and Bluetooh Low Energy (BLE) which are supported by ESP-IDF 5.3.2 and Arduino Core 3.1.0. +- Nuki Hub is compiled against all ESP32 models with Wi-Fi and Bluetooh Low Energy (BLE) which are supported by ESP-IDF 5.3.2 and Arduino Core 3.1.2. - Tested stable builds are provided for the ESP32, ESP32-S3, ESP32-C3, ESP32-C6 and ESP32-H2. - Untested builds are provided for the ESP32-Solo1 (as the developers don't own one). diff --git a/platformio.ini b/platformio.ini index 74401d4..caf0cff 100644 --- a/platformio.ini +++ b/platformio.ini @@ -13,7 +13,7 @@ default_envs = esp32 boards_dir = boards [env] -platform = https://github.com/pioarduino/platform-espressif32/releases/download/53.03.11/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/53.03.12/platform-espressif32.zip platform_packages = framework = arduino, espidf board_build.embed_txtfiles = diff --git a/src/idf_component.yml b/src/idf_component.yml index 5a9217d..74e26b3 100644 --- a/src/idf_component.yml +++ b/src/idf_component.yml @@ -4,7 +4,7 @@ dependencies: esp-nimble-cpp: git: https://github.com/h2zero/esp-nimble-cpp.git - version: fa468d360a56712f3f39a1fba74b97340ebca8a9 + version: 74b5c59887a381ca02c8193384bd89968c8409a6 espressif/libsodium: "^1.0.20~2" diff --git a/updater/platformio.ini b/updater/platformio.ini index 99e37d8..e1e2c71 100644 --- a/updater/platformio.ini +++ b/updater/platformio.ini @@ -13,7 +13,7 @@ default_envs = updater_esp32 boards_dir = ../boards [env] -platform = https://github.com/pioarduino/platform-espressif32/releases/download/53.03.11/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/53.03.12/platform-espressif32.zip platform_packages = framework = arduino, espidf board_build.embed_txtfiles = From b2fd9d93498e26894a1dd55ba8a014dd82f54f5e Mon Sep 17 00:00:00 2001 From: iranl Date: Tue, 11 Feb 2025 22:37:00 +0100 Subject: [PATCH 2/3] Bypass MFA --- README.md | 4 +- src/Config.h | 2 +- src/ImportExport.cpp | 49 ++++++++++-- src/ImportExport.h | 9 ++- src/NukiNetwork.cpp | 37 +++++---- src/NukiWrapper.cpp | 12 +-- src/PreferencesKeys.h | 5 +- src/WebCfgServer.cpp | 171 +++++++++++++++++++++++++++++++++++++++--- src/WebCfgServer.h | 3 + src/main.cpp | 8 +- 10 files changed, 254 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 4ba25b1..b683a0e 100644 --- a/README.md +++ b/README.md @@ -527,7 +527,7 @@ Note that the following options can break Nuki Hub and cause bootloops that will Consider this when deciding if you want to enable the following functionality: -- Any application/actor that has read access to `nukihub/configuration/action` and `nukihub/configuration/json` can view your changes and exports. +- Any application/actor that has read access to `nukihub/configuration/action` and `nukihub/configuration/json` can view your changes and exports. - If you have not enabled the setting to require MFA when changing settings any application/actor that has write access to `nukihub/configuration/action` can change Nuki Hub settings (including pairing data and credentials) ### Export Nuki Hub settings over MQTT @@ -562,7 +562,7 @@ After the import is complete the ESP32 will reboot. If you have enabled `Require MFA (Duo/TOTP) authentication for all sensitive Nuki Hub operations (changing/exporting settings)` you will need to either provide a currently valid TOTP code as part of the sent JSON in the `totp` node or approve the Duo Push before the settings will be changed/imported. -Note: When importing settings using MQTT there are less/no checks on the values entered. These checks are only available when changing settings through the WebConfigurator. +Note: When importing settings using MQTT there are less/no checks on the values entered. These checks are only available when changing settings through the WebConfigurator. Consider testing your configuration values by changing them in the Web Configurator before trying to use MQTT to change configuration. A general explanation of the values that can be imported can be found in the [PreferencesKeys.h](/src/PreferencesKeys.h) file diff --git a/src/Config.h b/src/Config.h index 711efab..cbc09ea 100644 --- a/src/Config.h +++ b/src/Config.h @@ -5,7 +5,7 @@ #define NUKI_HUB_VERSION "9.09" #define NUKI_HUB_VERSION_INT (uint32_t)909 #define NUKI_HUB_BUILD "unknownbuildnr" -#define NUKI_HUB_DATE "2025-02-11" +#define NUKI_HUB_DATE "2025-02-13" #define GITHUB_LATEST_RELEASE_URL (char*)"https://github.com/technyon/nuki_hub/releases/latest" #define GITHUB_OTA_MANIFEST_URL (char*)"https://raw.githubusercontent.com/technyon/nuki_hub/binary/ota/manifest.json" diff --git a/src/ImportExport.cpp b/src/ImportExport.cpp index 21d46e6..8d307c2 100644 --- a/src/ImportExport.cpp +++ b/src/ImportExport.cpp @@ -39,6 +39,8 @@ void ImportExport::readSettings() _totpKey = _preferences->getString(preference_totp_secret, ""); _totpEnabled = _totpKey.length() > 0; + _bypassKey = _preferences->getString(preference_bypass_secret, ""); + _bypassEnabled = _bypassKey.length() > 0; } bool ImportExport::getDuoEnabled() @@ -51,6 +53,11 @@ bool ImportExport::getTOTPEnabled() return _totpEnabled; } +bool ImportExport::getBypassEnabled() +{ + return _bypassEnabled; +} + bool ImportExport::getBypassGPIOEnabled() { return _bypassGPIO; @@ -132,7 +139,7 @@ int ImportExport::checkDuoAuth(PsychicRequest *request) const char* duo_ikey = _duoIkey.c_str(); const char* duo_skey = _duoSkey.c_str(); const char* duo_user = _duoUser.c_str(); - + int type = 0; if(request->hasParam("type")) { @@ -168,7 +175,7 @@ int ImportExport::checkDuoAuth(PsychicRequest *request) _duoTransactionId = ""; _duoCheckIP = ""; _duoCheckId = ""; - + if(type==0) { int64_t durationLength = 60*60*_preferences->getInt(preference_cred_session_lifetime_duo_remember, 720); @@ -200,7 +207,7 @@ int ImportExport::checkDuoAuth(PsychicRequest *request) _duoTransactionId = ""; _duoCheckIP = ""; _duoCheckId = ""; - + if(type==0) { if (_preferences->getBool(preference_mfa_reconfigure, false)) @@ -278,9 +285,9 @@ bool ImportExport::checkTOTP(String* totpKey) } _lastCodeCheck = espMillis(); - - String key(totpKey->c_str()); - + + String key(totpKey->c_str()); + time_t now; time(&now); int totpTime = -60; @@ -288,7 +295,7 @@ bool ImportExport::checkTOTP(String* totpKey) while (totpTime <= 60) { String key2(TOTP::currentOTP(now, _totpKey, 30, 6, totpTime)->c_str()); - + if(key.toInt() == key2.toInt()) { _invalidCount = 0; @@ -303,6 +310,30 @@ bool ImportExport::checkTOTP(String* totpKey) return false; } +bool ImportExport::checkBypass(String bypass) +{ + if(_bypassEnabled) + { + if((pow(_invalidCount2, 5) + _lastCodeCheck2) > espMillis()) + { + _lastCodeCheck2 = espMillis(); + return false; + } + + _lastCodeCheck2 = espMillis(); + + if(bypass == _bypassKey) + { + _invalidCount2 = 0; + Log->println("Successful Bypass MFA Auth"); + return true; + } + _invalidCount2++; + Log->println("Failed Bypass MFA Auth"); + } + return false; +} + void ImportExport::exportHttpsJson(JsonDocument &json) { if (!SPIFFS.begin(true)) { @@ -442,6 +473,10 @@ void ImportExport::exportNukiHubJson(JsonDocument &json, bool redacted, bool pai { continue; } + if(strcmp(key, preference_bypass_secret) == 0) + { + continue; + } if(!redacted) if(std::find(redactedPrefs.begin(), redactedPrefs.end(), key) != redactedPrefs.end()) { continue; diff --git a/src/ImportExport.h b/src/ImportExport.h index 302974b..7d04516 100644 --- a/src/ImportExport.h +++ b/src/ImportExport.h @@ -16,7 +16,9 @@ public: int checkDuoApprove(); bool startDuoAuth(char* pushType = (char*)""); bool getTOTPEnabled(); + bool getBypassEnabled(); bool checkTOTP(String* totpKey); + bool checkBypass(String bypass); bool getDuoEnabled(); bool getBypassGPIOEnabled(); int getBypassGPIOHigh(); @@ -27,13 +29,17 @@ public: JsonDocument _duoSessions; JsonDocument _totpSessions; JsonDocument _sessionsOpts; + JsonDocument _bypassSessions; int64_t _lastCodeCheck = 0; + int64_t _lastCodeCheck2 = 0; int _invalidCount = 0; + int _invalidCount2 = 0; private: void saveSessions(); Preferences* _preferences; struct tm timeinfo; bool _totpEnabled = false; + bool _bypassEnabled = false; bool _duoActiveRequest; bool _duoEnabled = false; bool _bypassGPIO = false; @@ -47,6 +53,7 @@ private: String _duoUser; String _duoCheckId; String _duoCheckIP; - String _totpKey; + String _totpKey; + String _bypassKey; }; diff --git a/src/NukiNetwork.cpp b/src/NukiNetwork.cpp index 608ff6a..8684d4a 100644 --- a/src/NukiNetwork.cpp +++ b/src/NukiNetwork.cpp @@ -384,12 +384,17 @@ bool NukiNetwork::update() wdt_hal_write_protect_enable(&rtc_wdt_ctx); int64_t ts = espMillis(); _device->update(); - + if(_importExport->getTOTPEnabled() && _importExport->_invalidCount > 0 && (ts - (120000 * _importExport->_invalidCount)) > _importExport->_lastCodeCheck) { _importExport->_invalidCount--; } + if(_importExport->getBypassEnabled() && _importExport->_invalidCount2 > 0 && (ts - (120000 * _importExport->_invalidCount2)) > _importExport->_lastCodeCheck2) + { + _importExport->_invalidCount2--; + } + if(disableNetwork || !_mqttEnabled || _device->isApOpen()) { return false; @@ -477,7 +482,7 @@ bool NukiNetwork::update() _lastRssi = rssi; } } - + if(_overwriteNukiHubConfigTS > 0 && espMillis() > _overwriteNukiHubConfigTS) { publishString(_maintenancePathPrefix, mqtt_topic_nuki_hub_config_json, "--", true); @@ -1101,7 +1106,7 @@ void NukiNetwork::onMqttDataReceived(const char* topic, byte* payload, const uns if(timeSynced && _importExport->getTOTPEnabled() && !doc["totp"].isNull()) { String jsonTotp = doc["totp"]; - + if (!_importExport->checkTOTP(&jsonTotp)) { publishString(_maintenancePathPrefix, mqtt_topic_nuki_hub_config_action_command_result, "{\"error\": \"totpIncorrect\"}", false); publishString(_maintenancePathPrefix, mqtt_topic_nuki_hub_config_action, "--", true); @@ -1114,15 +1119,19 @@ void NukiNetwork::onMqttDataReceived(const char* topic, byte* payload, const uns publishString(_maintenancePathPrefix, mqtt_topic_nuki_hub_config_action, "--", true); return; } - else if (_importExport->startDuoAuth((char*)"Approve Nuki Hub setting change")) + else { + bool duoRes = _importExport->startDuoAuth((char*)"Approve Nuki Hub setting change"); int duoResult = 2; - while (duoResult == 2) + if (duoRes) { - duoResult = _importExport->checkDuoApprove(); - delay(2000); - esp_task_wdt_reset(); + while (duoResult == 2) + { + duoResult = _importExport->checkDuoApprove(); + delay(2000); + esp_task_wdt_reset(); + } } if (duoResult != 1) @@ -1133,18 +1142,18 @@ void NukiNetwork::onMqttDataReceived(const char* topic, byte* payload, const uns } } } - + if(!doc["exportHTTPS"].isNull() && _device->isEncrypted()) { if(_preferences->getBool(preference_publish_config, false)) { - if(_device->isEncrypted()) + if(_device->isEncrypted()) { JsonDocument json; _importExport->exportHttpsJson(json); serializeJson(json, _buffer, _bufferSize); publishString(_maintenancePathPrefix, mqtt_topic_nuki_hub_config_json, _buffer, false); - + if (doc["exportHTTPS"].as() > 0) { _overwriteNukiHubConfigTS = espMillis() + (doc["exportHTTPS"].as() * 1000); @@ -1164,7 +1173,7 @@ void NukiNetwork::onMqttDataReceived(const char* topic, byte* payload, const uns { if(_preferences->getBool(preference_publish_config, false)) { - if(_device->isEncrypted()) + if(_device->isEncrypted()) { JsonDocument json; _importExport->exportMqttsJson(json); @@ -1193,7 +1202,7 @@ void NukiNetwork::onMqttDataReceived(const char* topic, byte* payload, const uns bool redacted = false; if(!doc["redacted"].isNull()) { - if(_device->isEncrypted()) + if(_device->isEncrypted()) { redacted = true; } @@ -1205,7 +1214,7 @@ void NukiNetwork::onMqttDataReceived(const char* topic, byte* payload, const uns bool pairing = false; if(!doc["pairing"].isNull()) { - if(_device->isEncrypted()) + if(_device->isEncrypted()) { pairing = true; } diff --git a/src/NukiWrapper.cpp b/src/NukiWrapper.cpp index 7ef9542..3be7a82 100644 --- a/src/NukiWrapper.cpp +++ b/src/NukiWrapper.cpp @@ -62,7 +62,7 @@ void NukiWrapper::initialize() _nukiLock.setDebugHexData(_preferences->getBool(preference_debug_hex_data, false)); _nukiLock.setDebugCommand(_preferences->getBool(preference_debug_command, false)); _nukiLock.registerLogger(Log); - + if (_preferences->getInt(preference_lock_gemini_pin, 0) > 0 && _preferences->getBool(preference_lock_gemini_enabled, false)) { _nukiLock.saveUltraPincode(_preferences->getInt(preference_lock_gemini_pin, 0), false); @@ -90,7 +90,7 @@ void NukiWrapper::readSettings() #else if(pwrLvl >= 20) { - powerLevel = ESP_PWR_LVL_P20; + powerLevel = ESP_PWR_LVL_P20; } else if(pwrLvl >= 18) { @@ -486,7 +486,7 @@ void NukiWrapper::unpair() nukiBlePref.clear(); nukiBlePref.end(); _deviceId->assignNewId(); - if (!_forceId) + if (!_forceId) { _preferences->remove(preference_nuki_id_lock); } @@ -4302,18 +4302,18 @@ void NukiWrapper::updateTime() Log->println("No valid PIN set"); return; } - + time_t now; tm tm; time(&now); localtime_r(&now, &tm); - + if (int(tm.tm_year + 1900) < int(2025)) { Log->println("NTP Time not valid, not updating Nuki device"); return; } - + Nuki::TimeValue nukiTime; nukiTime.year = tm.tm_year + 1900; nukiTime.month = tm.tm_mon + 1; diff --git a/src/PreferencesKeys.h b/src/PreferencesKeys.h index 6658dff..6722fce 100644 --- a/src/PreferencesKeys.h +++ b/src/PreferencesKeys.h @@ -96,6 +96,7 @@ #define preference_publish_config (char*)"nhPubConfig" #define preference_config_from_mqtt (char*)"nhCntrlEnabled" #define preference_totp_secret (char*)"totpsecret" +#define preference_bypass_secret (char*)"bypassecret" // CHANGE DOES NOT REQUIRE REBOOT TO TAKE EFFECT #define preference_find_best_rssi (char*)"nwbestrssi" @@ -535,13 +536,13 @@ private: preference_cred_duo_host, preference_cred_duo_ikey, preference_cred_duo_skey, preference_cred_duo_user, preference_cred_duo_enabled, preference_https_fqdn, preference_bypass_proxy, preference_cred_session_lifetime, preference_cred_session_lifetime_remember, preference_cred_session_lifetime_duo, preference_cred_session_lifetime_duo_remember, preference_cred_duo_approval, preference_cred_bypass_boot_btn_enabled, preference_cred_bypass_gpio_high, preference_cred_bypass_gpio_low, preference_publish_config, - preference_config_from_mqtt, preference_totp_secret, preference_cred_session_lifetime_totp, preference_cred_session_lifetime_totp_remember + preference_config_from_mqtt, preference_totp_secret, preference_cred_session_lifetime_totp, preference_cred_session_lifetime_totp_remember, preference_bypass_secret }; std::vector _redact = { preference_mqtt_user, preference_mqtt_password, preference_cred_user, preference_cred_password, preference_nuki_id_lock, preference_nuki_id_opener, preference_wifi_pass, preference_lock_gemini_pin, preference_cred_duo_host, preference_cred_duo_ikey, preference_cred_duo_skey, preference_cred_duo_user, preference_bypass_proxy, - preference_totp_secret + preference_totp_secret, preference_bypass_secret }; std::vector _boolPrefs = { diff --git a/src/WebCfgServer.cpp b/src/WebCfgServer.cpp index d9f8843..bb14218 100644 --- a/src/WebCfgServer.cpp +++ b/src/WebCfgServer.cpp @@ -102,18 +102,22 @@ bool WebCfgServer::isAuthenticated(PsychicRequest *request, int type) { cookieKey = "totpId"; } + else if (type == 3) + { + cookieKey = "bypassId"; + } if (request->hasCookie(cookieKey.c_str())) { String cookie = request->getCookie(cookieKey.c_str()); - if ((type == 0 && _httpSessions[cookie].is()) || (type == 1 && _importExport->_duoSessions[cookie].is()) || (type == 2 && _importExport->_totpSessions[cookie].is())) + if ((type == 0 && _httpSessions[cookie].is()) || (type == 1 && _importExport->_duoSessions[cookie].is()) || (type == 2 && _importExport->_totpSessions[cookie].is()) || (type == 3 && _importExport->_bypassSessions[cookie].is())) { struct timeval time; gettimeofday(&time, NULL); int64_t time_us = (int64_t)time.tv_sec * 1000000L + (int64_t)time.tv_usec; - if ((type == 0 && _httpSessions[cookie].as() > time_us) || (type == 1 && _importExport->_duoSessions[cookie].as() > time_us) || (type == 2 && _importExport->_totpSessions[cookie].as() > time_us)) + if ((type == 0 && _httpSessions[cookie].as() > time_us) || (type == 1 && _importExport->_duoSessions[cookie].as() > time_us) || (type == 2 && _importExport->_totpSessions[cookie].as() > time_us) || (type == 3 && _importExport->_bypassSessions[cookie].as() > time_us)) { return true; } @@ -194,6 +198,23 @@ esp_err_t WebCfgServer::logoutSession(PsychicRequest *request, PsychicResponse* } } + if (_importExport->getBypassEnabled()) + { + if (!_isSSL) + { + resp->setCookie("bypassId", "", 0, "HttpOnly"); + } + else + { + resp->setCookie("bypassId", "", 0, "Secure; HttpOnly"); + } + + if (request->hasCookie("bypassId")) { + String cookie2 = request->getCookie("bypassId"); + _importExport->_bypassSessions.remove(cookie2); + } + } + return buildConfirmHtml(request, resp, "Logging out", 3, true); } @@ -369,6 +390,11 @@ int WebCfgServer::doAuthentication(PsychicRequest *request) _importExport->_sessionsOpts[request->client()->localIP().toString() + "totp"] = true; return 4; } + else if(!timeSynced && _importExport->getBypassEnabled() && isAuthenticated(request, 3)) + { + _importExport->_sessionsOpts[request->client()->localIP().toString() + "totp"] = false; + return 4; + } Log->println("Authentication Failed"); @@ -507,7 +533,7 @@ void WebCfgServer::initialize() } int authReq = doAuthentication(request); - if (value != "status" && value != "login" && value != "duocheck") + if (value != "status" && value != "login" && value != "duocheck" && value != "bypass") { switch (authReq) { @@ -559,6 +585,15 @@ void WebCfgServer::initialize() { return buildTOTPHtml(request, resp, 0); } + else if (value == "bypass") + { + return buildBypassHtml(request, resp); + } + else if (value == "newbypass" && _newBypass) + { + _newBypass = false; + return buildConfirmHtml(request, resp, "Logged in using Bypass. New bypass: " + _preferences->getString(preference_bypass_secret, ""), 3, false); + } else if (value == "logout") { return logoutSession(request, resp); @@ -816,7 +851,7 @@ void WebCfgServer::initialize() } } - if (value != "login" && value != "totp") + if (value != "login" && value != "totp" && value != "bypass") { int authReq = doAuthentication(request); @@ -866,6 +901,11 @@ void WebCfgServer::initialize() } } } + else if(!timeSynced && _importExport->getBypassEnabled() && isAuthenticated(request, 3)) + { + _importExport->_sessionsOpts[request->client()->localIP().toString() + "approve"] = false; + approved = true; + } if (!approved) { @@ -926,6 +966,23 @@ void WebCfgServer::initialize() return resp->redirect("/get?page=totp"); } } + else if (value == "bypass") + { + bool loggedIn = processBypass(request, resp); + if (loggedIn) + { + resp->setCode(302); + resp->addHeader("Cache-Control", "no-cache"); + _newBypass = true; + return resp->redirect("/get?page=newbypass"); + } + else + { + resp->setCode(302); + resp->addHeader("Cache-Control", "no-cache"); + return resp->redirect("/"); + } + } #ifndef NUKI_HUB_UPDATER else if (value == "savecfg") { @@ -1882,14 +1939,14 @@ esp_err_t WebCfgServer::buildTOTPHtml(PsychicRequest *request, PsychicResponse* { if (!timeSynced) { - return buildConfirmHtml(request, resp, "NTP time not synced yet, TOTP not available, please wait for NTP to sync", 3, true); + return buildConfirmHtml(request, resp, "NTP time not synced yet, TOTP not available, please wait for NTP to sync or use one-time bypass", 3, true); } - + if((pow(_importExport->_invalidCount, 5) + _importExport->_lastCodeCheck) > espMillis()) { - return buildConfirmHtml(request, resp, "Too many invalid TOTP tries, please wait before retrying", 3, true); + return buildConfirmHtml(request, resp, "Too many invalid TOTP tries, please wait before retrying or use one-time bypass", 3, true); } - + PsychicStreamResponse response(resp, "text/html"); response.beginSend(); response.print(""); @@ -1943,6 +2000,32 @@ esp_err_t WebCfgServer::buildTOTPHtml(PsychicRequest *request, PsychicResponse* return response.endSend(); } +esp_err_t WebCfgServer::buildBypassHtml(PsychicRequest *request, PsychicResponse* resp) +{ + if (timeSynced) + { + return buildConfirmHtml(request, resp, "One-time bypass is only available if NTP time is not synced", 3, true); + } + + if((pow(_importExport->_invalidCount2, 5) + _importExport->_lastCodeCheck2) > espMillis()) + { + return buildConfirmHtml(request, resp, "Too many invalid bypass tries, please wait before retrying", 3, true); + } + + PsychicStreamResponse response(resp, "text/html"); + response.beginSend(); + response.print(""); + response.print(""); + response.print("

Nuki Hub One-time Bypass

"); + response.print("
"); + response.print("
"); + response.print(""); + response.print("
"); + response.print("
"); + return response.endSend(); +} + esp_err_t WebCfgServer::buildDuoCheckHtml(PsychicRequest *request, PsychicResponse* resp) { char valueStr[2]; @@ -1986,7 +2069,7 @@ esp_err_t WebCfgServer::buildDuoHtml(PsychicRequest *request, PsychicResponse* r { if (!timeSynced) { - return buildConfirmHtml(request, resp, "NTP time not synced yet, Duo not available, please wait for NTP to sync", 3, true); + return buildConfirmHtml(request, resp, "NTP time not synced yet, Duo not available, please wait for NTP to sync or use one-time bypass", 3, true); } String duoText; @@ -2130,6 +2213,52 @@ bool WebCfgServer::processLogin(PsychicRequest *request, PsychicResponse* resp) return false; } +bool WebCfgServer::processBypass(PsychicRequest *request, PsychicResponse* resp) +{ + if(!timeSynced && request->hasParam("bypass")) + { + const PsychicWebParameter* pass = request->getParam("bypass"); + if(pass->value() != "") + { + String bypass = pass->value(); + if (_importExport->checkBypass(bypass)) + { + char buffer[33]; + int i; + for (i = 0; i < 4; i++) { + sprintf(buffer + (i * 8), "%08lx", (unsigned long int)esp_random()); + } + + if (!_isSSL) + { + resp->setCookie("bypassId", buffer, 3600, "HttpOnly"); + } + else + { + resp->setCookie("bypassId", buffer, 3600, "Secure; HttpOnly"); + } + + struct timeval time; + gettimeofday(&time, NULL); + int64_t time_us = (int64_t)time.tv_sec * 1000000L + (int64_t)time.tv_usec; + _importExport->_bypassSessions[buffer] = time_us + ((int64_t)3600*1000000L); + + char randomstr2[33]; + randomSeed(analogRead(0)); + char chars[] = {'1', '2', '3','4', '5', '6','7', '8', '9', '0', 'A', 'B', 'C', 'D','E', 'F', 'G','H', 'I', 'J','K', 'L', 'M', 'N', 'O','P', 'Q','R', 'S', 'T','U', 'V', 'W','X', 'Y', 'Z'}; + for(int i = 0;i < 32; i++){ + randomstr2[i] = chars[random(36)]; + } + randomstr2[32] = '\0'; + _preferences->putString(preference_bypass_secret, randomstr2); + + return true; + } + } + } + return false; +} + bool WebCfgServer::processTOTP(PsychicRequest *request, PsychicResponse* resp) { if(timeSynced && request->hasParam("totpkey")) @@ -4151,6 +4280,19 @@ bool WebCfgServer::processArgs(PsychicRequest *request, PsychicResponse* resp, S } } } + else if(key == "CREDBYPASS") + { + if(value != "*") + { + if(_preferences->getString(preference_bypass_secret, "") != value) + { + _preferences->putString(preference_bypass_secret, value); + Log->print("Setting changed: "); + Log->println(key); + configChanged = true; + } + } + } else if(key == "NUKIPIN" && _nuki != nullptr) { if(value == "#") @@ -4708,6 +4850,13 @@ esp_err_t WebCfgServer::buildCredHtml(PsychicRequest *request, PsychicResponse* randomstr[i] = chars[random(32)]; } randomstr[16] = '\0'; + char randomstr2[33]; + randomSeed(analogRead(0)); + char chars2[] = {'1', '2', '3','4', '5', '6','7', '8', '9', '0', 'A', 'B', 'C', 'D','E', 'F', 'G','H', 'I', 'J','K', 'L', 'M', 'N', 'O','P', 'Q','R', 'S', 'T','U', 'V', 'W','X', 'Y', 'Z'}; + for(int i = 0;i < 32; i++){ + randomstr2[i] = chars2[random(36)]; + } + randomstr2[32] = '\0'; PsychicStreamResponse response(resp, "text/html"); response.beginSend(); @@ -4738,6 +4887,10 @@ esp_err_t WebCfgServer::buildCredHtml(PsychicRequest *request, PsychicResponse* response.print(""); + printInputField(&response, "CREDBYPASS", "One-time MFA Bypass", "*", 32, "", true, false); + response.print(""); printInputField(&response, "CREDLFTM", "Session validity (in seconds)", _preferences->getInt(preference_cred_session_lifetime, 3600), 12, ""); printInputField(&response, "CREDLFTMRMBR", "Session validity remember (in hours)", _preferences->getInt(preference_cred_session_lifetime_remember, 720), 12, ""); printInputField(&response, "CREDDUOLFTM", "Duo Session validity (in seconds)", _preferences->getInt(preference_cred_session_lifetime_duo, 3600), 12, ""); diff --git a/src/WebCfgServer.h b/src/WebCfgServer.h index 6e3e763..fbc5bdb 100644 --- a/src/WebCfgServer.h +++ b/src/WebCfgServer.h @@ -111,9 +111,11 @@ private: bool isAuthenticated(PsychicRequest *request, int type = 0); bool processLogin(PsychicRequest *request, PsychicResponse* resp); bool processTOTP(PsychicRequest *request, PsychicResponse* resp); + bool processBypass(PsychicRequest *request, PsychicResponse* resp); int doAuthentication(PsychicRequest *request); esp_err_t buildCoredumpHtml(PsychicRequest *request, PsychicResponse* resp); esp_err_t buildLoginHtml(PsychicRequest *request, PsychicResponse* resp); + esp_err_t buildBypassHtml(PsychicRequest *request, PsychicResponse* resp); esp_err_t buildTOTPHtml(PsychicRequest *request, PsychicResponse* resp, int type); esp_err_t buildDuoHtml(PsychicRequest *request, PsychicResponse* resp, int type); esp_err_t buildDuoCheckHtml(PsychicRequest *request, PsychicResponse* resp); @@ -150,6 +152,7 @@ private: JsonDocument _httpSessions; bool _duoEnabled = false; bool _bypassGPIO = false; + bool _newBypass = false; int _bypassGPIOHigh = -1; int _bypassGPIOLow = -1; }; diff --git a/src/main.cpp b/src/main.cpp index 142450f..3be50bc 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -546,7 +546,7 @@ void logCoreDump() file.printf("%s\r\n", NUKI_HUB_HW); file.printf("%s\r\n", NUKI_HUB_BUILD); } - + Serial.printf("%s\r\n", NUKI_HUB_HW); Serial.printf("%s\r\n", NUKI_HUB_BUILD); @@ -581,7 +581,7 @@ void logCoreDump() file.printf("%s", str_dst); } } - + Serial.println(""); if (file) { @@ -669,7 +669,7 @@ void setup() { preferences->putString(preference_updater_date, NUKI_HUB_DATE); } - + importExport = new ImportExport(preferences); network = new NukiNetwork(preferences); @@ -796,7 +796,7 @@ void setup() Log->print(gpioDesc.c_str()); const String mqttLockPath = preferences->getString(preference_mqtt_lock_path); - + importExport = new ImportExport(preferences); network = new NukiNetwork(preferences, gpio, mqttLockPath, CharBuffer::get(), buffer_size, importExport); From 92a6ca5b354b1bce9d49317eba7a5414b2a91c91 Mon Sep 17 00:00:00 2001 From: iranl Date: Fri, 14 Feb 2025 20:32:15 +0100 Subject: [PATCH 3/3] Add Admin key --- README.md | 2 + src/Config.h | 2 +- src/ImportExport.cpp | 4 ++ src/PreferencesKeys.h | 6 +- src/WebCfgServer.cpp | 134 +++++++++++++++++++++++++++++++++++++----- src/WebCfgServer.h | 2 +- 6 files changed, 131 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index b683a0e..23fcb54 100644 --- a/README.md +++ b/README.md @@ -333,6 +333,8 @@ Note: All of the following requires the Nuki security code / PIN to be set, see - Duo secret key: Set to the Duo secret key - Duo user: Set to the Duo user that you want to receive the push notification - TOTP Secret Key: Set a TOTP secret key to enable TOTP MFA. Enter the TOTP secret key in an authenticator application (Password manager, Microsoft/Google Authenticator etc.) to generate TOTP codes. +- One-time MFA Bypass: Set a 32 character long alphanumeric string that can be used as a one-time MFA bypass when the ESP32 is unable to sync it's time and TOTP and Duo are unavailable as a result. +- Admin key: Set a 32 character long alphanumeric string that can be used in combination with a TOTP code to export and import settings without needing to log in (for use with automated systems). - Session validity (in seconds): Session validity to use with form authentication when the "Remember me" checkbox is disabled, default 3600 seconds. - Session validity remember (in hours): Session validity to use with form authentication when the "Remember me" checkbox is enabled, default 720 hours. - Duo Session validity (in seconds): Session validity to use with Duo authentication when the "Remember me" checkbox is disabled, default 3600 seconds. diff --git a/src/Config.h b/src/Config.h index cbc09ea..5f64dce 100644 --- a/src/Config.h +++ b/src/Config.h @@ -5,7 +5,7 @@ #define NUKI_HUB_VERSION "9.09" #define NUKI_HUB_VERSION_INT (uint32_t)909 #define NUKI_HUB_BUILD "unknownbuildnr" -#define NUKI_HUB_DATE "2025-02-13" +#define NUKI_HUB_DATE "2025-02-14" #define GITHUB_LATEST_RELEASE_URL (char*)"https://github.com/technyon/nuki_hub/releases/latest" #define GITHUB_OTA_MANIFEST_URL (char*)"https://raw.githubusercontent.com/technyon/nuki_hub/binary/ota/manifest.json" diff --git a/src/ImportExport.cpp b/src/ImportExport.cpp index 8d307c2..049be5c 100644 --- a/src/ImportExport.cpp +++ b/src/ImportExport.cpp @@ -477,6 +477,10 @@ void ImportExport::exportNukiHubJson(JsonDocument &json, bool redacted, bool pai { continue; } + if(strcmp(key, preference_admin_secret) == 0) + { + continue; + } if(!redacted) if(std::find(redactedPrefs.begin(), redactedPrefs.end(), key) != redactedPrefs.end()) { continue; diff --git a/src/PreferencesKeys.h b/src/PreferencesKeys.h index 6722fce..9cfcdef 100644 --- a/src/PreferencesKeys.h +++ b/src/PreferencesKeys.h @@ -97,6 +97,7 @@ #define preference_config_from_mqtt (char*)"nhCntrlEnabled" #define preference_totp_secret (char*)"totpsecret" #define preference_bypass_secret (char*)"bypassecret" +#define preference_admin_secret (char*)"adminsecret" // CHANGE DOES NOT REQUIRE REBOOT TO TAKE EFFECT #define preference_find_best_rssi (char*)"nwbestrssi" @@ -536,13 +537,14 @@ private: preference_cred_duo_host, preference_cred_duo_ikey, preference_cred_duo_skey, preference_cred_duo_user, preference_cred_duo_enabled, preference_https_fqdn, preference_bypass_proxy, preference_cred_session_lifetime, preference_cred_session_lifetime_remember, preference_cred_session_lifetime_duo, preference_cred_session_lifetime_duo_remember, preference_cred_duo_approval, preference_cred_bypass_boot_btn_enabled, preference_cred_bypass_gpio_high, preference_cred_bypass_gpio_low, preference_publish_config, - preference_config_from_mqtt, preference_totp_secret, preference_cred_session_lifetime_totp, preference_cred_session_lifetime_totp_remember, preference_bypass_secret + preference_config_from_mqtt, preference_totp_secret, preference_cred_session_lifetime_totp, preference_cred_session_lifetime_totp_remember, preference_bypass_secret, + preference_admin_secret }; std::vector _redact = { preference_mqtt_user, preference_mqtt_password, preference_cred_user, preference_cred_password, preference_nuki_id_lock, preference_nuki_id_opener, preference_wifi_pass, preference_lock_gemini_pin, preference_cred_duo_host, preference_cred_duo_ikey, preference_cred_duo_skey, preference_cred_duo_user, preference_bypass_proxy, - preference_totp_secret, preference_bypass_secret + preference_totp_secret, preference_bypass_secret, preference_admin_secret }; std::vector _boolPrefs = { diff --git a/src/WebCfgServer.cpp b/src/WebCfgServer.cpp index bb14218..77d492f 100644 --- a/src/WebCfgServer.cpp +++ b/src/WebCfgServer.cpp @@ -531,10 +531,38 @@ void WebCfgServer::initialize() value = p->value(); } } - int authReq = doAuthentication(request); - if (value != "status" && value != "login" && value != "duocheck" && value != "bypass") + bool adminKeyValid = false; + if(value == "export" && timeSynced && request->hasParam("adminkey") && request->hasParam("totpkey") && _importExport->getTOTPEnabled()) { + String value2 = ""; + if(request->hasParam("adminkey")) + { + const PsychicWebParameter* p = request->getParam("adminkey"); + if(p->value() != "") + { + value2 = p->value(); + } + } + String value3 = ""; + if(request->hasParam("totpkey")) + { + const PsychicWebParameter* p = request->getParam("totpkey"); + if(p->value() != "") + { + value3 = p->value(); + } + } + if (value2.length() > 0 && value2 == _preferences->getString(preference_admin_secret, "") && _importExport->checkTOTP(&value3)) + { + adminKeyValid = true; + } + } + + if (!adminKeyValid && value != "status" && value != "login" && value != "duocheck" && value != "bypass") + { + int authReq = doAuthentication(request); + switch (authReq) { case 0: @@ -569,14 +597,16 @@ void WebCfgServer::initialize() break; } } - else if (value == "status" && authReq != 4) + else if (value == "status") { - resp->setCode(200); - resp->setContentType("application/json"); - resp->setContent("{}"); - return resp->send(); + if (doAuthentication(request) != 4) + { + resp->setCode(200); + resp->setContentType("application/json"); + resp->setContent("{}"); + return resp->send(); + } } - if (value == "login") { return buildLoginHtml(request, resp); @@ -592,7 +622,7 @@ void WebCfgServer::initialize() else if (value == "newbypass" && _newBypass) { _newBypass = false; - return buildConfirmHtml(request, resp, "Logged in using Bypass. New bypass: " + _preferences->getString(preference_bypass_secret, ""), 3, false); + return buildConfirmHtml(request, resp, "Logged in using Bypass. New bypass: " + _preferences->getString(preference_bypass_secret, "") + "

Home page", 3, false); } else if (value == "logout") { @@ -659,6 +689,11 @@ void WebCfgServer::initialize() return sendSettings(request, resp); } + if(adminKeyValid) + { + return sendSettings(request, resp, true); + } + if(_importExport->_sessionsOpts[request->client()->localIP().toString() + "approve"]) { _importExport->_sessionsOpts[request->client()->localIP().toString() + "approve"] = false; @@ -851,7 +886,33 @@ void WebCfgServer::initialize() } } - if (value != "login" && value != "totp" && value != "bypass") + bool adminKeyValid = false; + if(value == "import" && timeSynced && request->hasParam("adminkey") && request->hasParam("totpkey") && _importExport->getTOTPEnabled()) + { + String value2 = ""; + if(request->hasParam("adminkey")) + { + const PsychicWebParameter* p = request->getParam("adminkey"); + if(p->value() != "") + { + value2 = p->value(); + } + } + String value3 = ""; + if(request->hasParam("totpkey")) + { + const PsychicWebParameter* p = request->getParam("totpkey"); + if(p->value() != "") + { + value3 = p->value(); + } + } + if (value2.length() > 0 && value2 == _preferences->getString(preference_admin_secret, "") && _importExport->checkTOTP(&value3)) + { + adminKeyValid = true; + } + } + if(!adminKeyValid && value != "login" && value != "totp" && value != "bypass") { int authReq = doAuthentication(request); @@ -1026,7 +1087,23 @@ void WebCfgServer::initialize() { String message = ""; bool restart = processImport(request, resp, message); - return buildConfirmHtml(request, resp, message, 3, true); + + if(adminKeyValid) + { + resp->setCode(200); + resp->setContentType("application/json"); + resp->setContent("{ \"result\": \"success\"}"); + esp_err_t res = resp->send(); + if(restart) + { + restartEsp(RestartReason::RequestedViaWebServer); + } + return res; + } + else + { + return buildConfirmHtml(request, resp, message, 3, true); + } } #endif else @@ -2303,7 +2380,7 @@ bool WebCfgServer::processTOTP(PsychicRequest *request, PsychicResponse* resp) } #ifndef NUKI_HUB_UPDATER -esp_err_t WebCfgServer::sendSettings(PsychicRequest *request, PsychicResponse* resp) +esp_err_t WebCfgServer::sendSettings(PsychicRequest *request, PsychicResponse* resp, bool adminKey) { JsonDocument json; String jsonPretty; @@ -2352,7 +2429,10 @@ esp_err_t WebCfgServer::sendSettings(PsychicRequest *request, PsychicResponse* r serializeJsonPretty(json, jsonPretty); char buf[26 + name.length()]; snprintf(buf, sizeof(buf), "attachment; filename=\"%s\"", name.c_str()); - resp->addHeader("Content-Disposition", buf); + if(!adminKey) + { + resp->addHeader("Content-Disposition", buf); + } resp->setCode(200); resp->setContentType("application/json"); resp->setContent(jsonPretty.c_str()); @@ -4293,6 +4373,19 @@ bool WebCfgServer::processArgs(PsychicRequest *request, PsychicResponse* resp, S } } } + else if(key == "CREDADMIN") + { + if(value != "*") + { + if(_preferences->getString(preference_admin_secret, "") != value) + { + _preferences->putString(preference_admin_secret, value); + Log->print("Setting changed: "); + Log->println(key); + configChanged = true; + } + } + } else if(key == "NUKIPIN" && _nuki != nullptr) { if(value == "#") @@ -4843,20 +4936,27 @@ esp_err_t WebCfgServer::buildHtml(PsychicRequest *request, PsychicResponse* resp esp_err_t WebCfgServer::buildCredHtml(PsychicRequest *request, PsychicResponse* resp) { + char chars[] = {'2', '3','4', '5', '6','7', 'A', 'B', 'C', 'D','E', 'F', 'G','H', 'I', 'J','K', 'L', 'M', 'N', 'O','P', 'Q','R', 'S', 'T','U', 'V', 'W','X', 'Y', 'Z'}; + char chars2[] = {'1', '2', '3','4', '5', '6','7', '8', '9', '0', 'A', 'B', 'C', 'D','E', 'F', 'G','H', 'I', 'J','K', 'L', 'M', 'N', 'O','P', 'Q','R', 'S', 'T','U', 'V', 'W','X', 'Y', 'Z'}; + char randomstr[17]; randomSeed(analogRead(0)); - char chars[] = {'2', '3','4', '5', '6','7', 'A', 'B', 'C', 'D','E', 'F', 'G','H', 'I', 'J','K', 'L', 'M', 'N', 'O','P', 'Q','R', 'S', 'T','U', 'V', 'W','X', 'Y', 'Z'}; for(int i = 0;i < 16; i++){ randomstr[i] = chars[random(32)]; } randomstr[16] = '\0'; char randomstr2[33]; randomSeed(analogRead(0)); - char chars2[] = {'1', '2', '3','4', '5', '6','7', '8', '9', '0', 'A', 'B', 'C', 'D','E', 'F', 'G','H', 'I', 'J','K', 'L', 'M', 'N', 'O','P', 'Q','R', 'S', 'T','U', 'V', 'W','X', 'Y', 'Z'}; for(int i = 0;i < 32; i++){ randomstr2[i] = chars2[random(36)]; } randomstr2[32] = '\0'; + char randomstr3[33]; + randomSeed(analogRead(0)); + for(int i = 0;i < 32; i++){ + randomstr3[i] = chars2[random(36)]; + } + randomstr3[32] = '\0'; PsychicStreamResponse response(resp, "text/html"); response.beginSend(); @@ -4891,6 +4991,10 @@ esp_err_t WebCfgServer::buildCredHtml(PsychicRequest *request, PsychicResponse* response.print(""); + printInputField(&response, "CREDADMIN", "Admin key", "*", 32, "", true, false); + response.print(""); printInputField(&response, "CREDLFTM", "Session validity (in seconds)", _preferences->getInt(preference_cred_session_lifetime, 3600), 12, ""); printInputField(&response, "CREDLFTMRMBR", "Session validity remember (in hours)", _preferences->getInt(preference_cred_session_lifetime_remember, 720), 12, ""); printInputField(&response, "CREDDUOLFTM", "Duo Session validity (in seconds)", _preferences->getInt(preference_cred_session_lifetime_duo, 3600), 12, ""); diff --git a/src/WebCfgServer.h b/src/WebCfgServer.h index fbc5bdb..04a7224 100644 --- a/src/WebCfgServer.h +++ b/src/WebCfgServer.h @@ -52,7 +52,7 @@ public: private: #ifndef NUKI_HUB_UPDATER - esp_err_t sendSettings(PsychicRequest *request, PsychicResponse* resp); + esp_err_t sendSettings(PsychicRequest *request, PsychicResponse* resp, bool adminKey = false); bool processArgs(PsychicRequest *request, PsychicResponse* resp, String& message); bool processImport(PsychicRequest *request, PsychicResponse* resp, String& message); void processGpioArgs(PsychicRequest *request, PsychicResponse* resp);