mirror of
https://github.com/rstrouse/ESPSomfy-RTS.git
synced 2026-10-31 13:52:15 +01:00
Merge pull request #64 from cjkas/fix/secrets-write-only
fix: stop sending stored passwords and PIN back to clients
This commit is contained in:
commit
3a684e863d
8 changed files with 249 additions and 46 deletions
|
|
@ -8,9 +8,9 @@
|
||||||
<meta name="apple-mobile-web-app-title" content="ESPSomfy RTS App">
|
<meta name="apple-mobile-web-app-title" content="ESPSomfy RTS App">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||||
|
|
||||||
<link rel="stylesheet" href="main.css?v=3.0.14c" type="text/css" />
|
<link rel="stylesheet" href="main.css?v=3.0.14d" type="text/css" />
|
||||||
<link rel="stylesheet" href="widgets.css?v=3.0.14c" type="text/css" />
|
<link rel="stylesheet" href="widgets.css?v=3.0.14d" type="text/css" />
|
||||||
<link rel="stylesheet" href="icons.css?v=3.0.14c" type="text/css" />
|
<link rel="stylesheet" href="icons.css?v=3.0.14d" type="text/css" />
|
||||||
<link rel="icon" type="image/png" href="favicon.png" />
|
<link rel="icon" type="image/png" href="favicon.png" />
|
||||||
|
|
||||||
<!-- iPad retina icon -->
|
<!-- iPad retina icon -->
|
||||||
|
|
@ -114,7 +114,7 @@
|
||||||
rel="apple-touch-startup-image">
|
rel="apple-touch-startup-image">
|
||||||
|
|
||||||
|
|
||||||
<script type="text/javascript" src="index.js?v=3.0.14c"></script>
|
<script type="text/javascript" src="index.js?v=3.0.14d"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="divContainer" class="container main" data-auth="false">
|
<div id="divContainer" class="container main" data-auth="false">
|
||||||
|
|
@ -207,11 +207,11 @@
|
||||||
<label for="fldUsername">Username</label>
|
<label for="fldUsername">Username</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<input id="fldPassword" name="password" type="password" data-bind="security.password" length=32 placeholder="Password">
|
<input id="fldPassword" name="password" type="password" autocomplete="new-password" data-bind="security.password" length=32 placeholder="Password">
|
||||||
<label for="fldPassword">Password</label>
|
<label for="fldPassword">Password</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<input id="fldReenterPassword" name="password" type="password" data-bind="security.repeatpassword" length=32 placeholder="Re-enter Password">
|
<input id="fldReenterPassword" name="password" type="password" autocomplete="new-password" data-bind="security.repeatpassword" length=32 placeholder="Re-enter Password">
|
||||||
<label for="fldRenterPassword">Re-enter Password</label>
|
<label for="fldRenterPassword">Re-enter Password</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -305,7 +305,7 @@
|
||||||
<label for="fldSsid">Network SSID</label>
|
<label for="fldSsid">Network SSID</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<input id="fldPassphrase" name="passphrase" type="password" data-bind="wifi.passphrase" length=32 placeholder="Passphrase">
|
<input id="fldPassphrase" name="passphrase" type="password" autocomplete="new-password" data-bind="wifi.passphrase" length=32 placeholder="Passphrase">
|
||||||
<label for="fldPassphrase">Passphrase</label>
|
<label for="fldPassphrase">Passphrase</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -412,7 +412,7 @@
|
||||||
<label for="fldMqttUsername">Username</label>
|
<label for="fldMqttUsername">Username</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<input id="fldMqttPassword" name="mqtt-password" type="text" length=64 data-bind="mqtt.password" placeholder="Password">
|
<input id="fldMqttPassword" name="mqtt-password" type="password" autocomplete="new-password" length=64 data-bind="mqtt.password" placeholder="Password">
|
||||||
<label for="fldMqttPassword">Password</label>
|
<label for="fldMqttPassword">Password</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
|
|
|
||||||
|
|
@ -1236,7 +1236,7 @@ class UIBinder {
|
||||||
divCfg.style.display = 'none';
|
divCfg.style.display = 'none';
|
||||||
document.getElementById('icoConfig').className = 'icss-gear';
|
document.getElementById('icoConfig').className = 'icss-gear';
|
||||||
if (sockIsOpen) socket.send('leave:0');
|
if (sockIsOpen) socket.send('leave:0');
|
||||||
general.setSecurityConfig({ type: 0, username: '', password: '', pin: '', permissions: 0 });
|
general.setSecurityConfig({ type: 0, username: '', permissions: 0 });
|
||||||
}
|
}
|
||||||
toggleConfig() {
|
toggleConfig() {
|
||||||
if (this.isConfigOpen())
|
if (this.isConfigOpen())
|
||||||
|
|
@ -1617,20 +1617,22 @@ class General {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setSecurityConfig(security) {
|
setSecurityConfig(security) {
|
||||||
|
// The device never returns the stored password or PIN, only whether one is set.
|
||||||
|
this.savedSecurity = { type: security.type, username: security.username, hasPassword: makeBool(security.hasPassword), hasPin: makeBool(security.hasPin) };
|
||||||
// We need to transform the security object so that it can be set to the configuration.
|
// We need to transform the security object so that it can be set to the configuration.
|
||||||
let obj = {
|
let obj = {
|
||||||
security: {
|
security: {
|
||||||
type: security.type, username: security.username, password: security.password,
|
type: security.type, username: security.username, password: '', repeatpassword: '',
|
||||||
permissions: { configOnly: makeBool(security.permissions & 0x01) },
|
permissions: { configOnly: makeBool(security.permissions & 0x01) },
|
||||||
pin: {
|
pin: { d0: '', d1: '', d2: '', d3: '' }
|
||||||
d0: security.pin[0],
|
|
||||||
d1: security.pin[1],
|
|
||||||
d2: security.pin[2],
|
|
||||||
d3: security.pin[3]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
ui.toElement(document.getElementById('divSecurityOptions'), obj);
|
let pnl = document.getElementById('divSecurityOptions');
|
||||||
|
ui.toElement(pnl, obj);
|
||||||
|
let keepHint = 'Saved (leave blank to keep)';
|
||||||
|
pnl.querySelector('#fldPassword').placeholder = this.savedSecurity.hasPassword ? keepHint : 'Password';
|
||||||
|
pnl.querySelector('#fldReenterPassword').placeholder = this.savedSecurity.hasPassword ? keepHint : 'Re-enter Password';
|
||||||
|
pnl.querySelector('label[for="fldPinEntry"]').innerHTML = this.savedSecurity.hasPin ? 'Enter Pin (leave blank to keep)' : 'Enter Pin';
|
||||||
this.onSecurityTypeChanged();
|
this.onSecurityTypeChanged();
|
||||||
}
|
}
|
||||||
rebootDevice() {
|
rebootDevice() {
|
||||||
|
|
@ -1674,9 +1676,17 @@ class General {
|
||||||
sec.pin += security.pin[`d${i}`];
|
sec.pin += security.pin[`d${i}`];
|
||||||
}
|
}
|
||||||
sec.permissions |= security.permissions.configOnly ? 0x01 : 0x00;
|
sec.permissions |= security.permissions.configOnly ? 0x01 : 0x00;
|
||||||
|
// Blank secret fields keep the stored value while the type (and username) stay the same.
|
||||||
|
let saved = this.savedSecurity || {};
|
||||||
|
let keepPin = security.type === 1 && sec.pin === '' && saved.hasPin && saved.type === 1;
|
||||||
|
let keepPassword = security.type === 2 && sec.password === '' && security.repeatpassword === '' &&
|
||||||
|
saved.hasPassword && saved.type === 2 && sec.username === saved.username;
|
||||||
|
// Only send the secret for the selected type so the other one stays stored.
|
||||||
|
if (security.type !== 1 || keepPin) delete sec.pin;
|
||||||
|
if (security.type !== 2 || keepPassword) delete sec.password;
|
||||||
let confirm = '';
|
let confirm = '';
|
||||||
console.log(sec);
|
console.log(sec);
|
||||||
if (security.type === 1) { // Pin Entry
|
if (security.type === 1 && !keepPin) { // Pin Entry
|
||||||
// Make sure our pin is 4 digits.
|
// Make sure our pin is 4 digits.
|
||||||
if (sec.pin.length !== 4) {
|
if (sec.pin.length !== 4) {
|
||||||
ui.errorMessage('Invalid Pin').querySelector('.sub-message').innerHTML = 'Pins must be exactly 4 alpha-numeric values in length. Please enter a complete pin.';
|
ui.errorMessage('Invalid Pin').querySelector('.sub-message').innerHTML = 'Pins must be exactly 4 alpha-numeric values in length. Please enter a complete pin.';
|
||||||
|
|
@ -1693,7 +1703,8 @@ class General {
|
||||||
ui.errorMessage('Invalid Username').querySelector('.sub-message').innerHTML = 'The maximum username length is 32 characters.';
|
ui.errorMessage('Invalid Username').querySelector('.sub-message').innerHTML = 'The maximum username length is 32 characters.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (security.type === 2 && !keepPassword) { // Password
|
||||||
if (sec.password.length === 0) {
|
if (sec.password.length === 0) {
|
||||||
ui.errorMessage('No Password Provided').querySelector('.sub-message').innerHTML = 'You must provide a password for password security.';
|
ui.errorMessage('No Password Provided').querySelector('.sub-message').innerHTML = 'You must provide a password for password security.';
|
||||||
return;
|
return;
|
||||||
|
|
@ -1803,6 +1814,11 @@ class Wifi {
|
||||||
document.getElementById('cbHardwired').checked = settings.connType >= 2;
|
document.getElementById('cbHardwired').checked = settings.connType >= 2;
|
||||||
document.getElementById('cbFallbackWireless').checked = settings.connType === 3;
|
document.getElementById('cbFallbackWireless').checked = settings.connType === 3;
|
||||||
ui.toElement(pnl, settings);
|
ui.toElement(pnl, settings);
|
||||||
|
// The device never returns the stored passphrase; leave the field blank to keep it.
|
||||||
|
this.savedSsid = settings.wifi.ssid;
|
||||||
|
let fldPassphrase = document.getElementById('fldPassphrase');
|
||||||
|
fldPassphrase.value = '';
|
||||||
|
fldPassphrase.placeholder = settings.wifi.hasPassphrase ? 'Saved (leave blank to keep)' : 'Passphrase';
|
||||||
/*
|
/*
|
||||||
if (settings.connType >= 2) {
|
if (settings.connType >= 2) {
|
||||||
document.getElementById('divWiFiMode').style.display = 'none';
|
document.getElementById('divWiFiMode').style.display = 'none';
|
||||||
|
|
@ -1850,7 +1866,7 @@ class Wifi {
|
||||||
//document.getElementById('btnConnectWiFi').classList.remove('disabled');
|
//document.getElementById('btnConnectWiFi').classList.remove('disabled');
|
||||||
console.log(aps);
|
console.log(aps);
|
||||||
if (err) {
|
if (err) {
|
||||||
this.displayAPs({ connected: { name: '', passphrase: '' }, accessPoints: [] });
|
this.displayAPs({ connected: { name: '' }, accessPoints: [] });
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
this.displayAPs(aps);
|
this.displayAPs(aps);
|
||||||
|
|
@ -1955,6 +1971,7 @@ class Wifi {
|
||||||
saveNetwork() {
|
saveNetwork() {
|
||||||
let pnl = document.getElementById('divNetAdapter');
|
let pnl = document.getElementById('divNetAdapter');
|
||||||
let obj = ui.fromElement(pnl);
|
let obj = ui.fromElement(pnl);
|
||||||
|
if (this.keepSavedPassphrase(obj.wifi.ssid, obj.wifi.passphrase)) delete obj.wifi.passphrase;
|
||||||
obj.connType = obj.ethernet.hardwired ? (obj.ethernet.wirelessFallback ? 3 : 2) : 1;
|
obj.connType = obj.ethernet.hardwired ? (obj.ethernet.wirelessFallback ? 3 : 2) : 1;
|
||||||
console.log(obj);
|
console.log(obj);
|
||||||
if (obj.connType >= 2) {
|
if (obj.connType >= 2) {
|
||||||
|
|
@ -1991,6 +2008,8 @@ class Wifi {
|
||||||
this.sendNetworkSettings(obj);
|
this.sendNetworkSettings(obj);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A blank passphrase for the saved SSID keeps the stored one; for a new SSID it means an open network.
|
||||||
|
keepSavedPassphrase(ssid, passphrase) { return passphrase === '' && ssid === this.savedSsid; }
|
||||||
sendNetworkSettings(obj) {
|
sendNetworkSettings(obj) {
|
||||||
putJSONSync('/setNetwork', obj, (err, response) => {
|
putJSONSync('/setNetwork', obj, (err, response) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
|
@ -2014,6 +2033,7 @@ class Wifi {
|
||||||
ui.errorMessage('Invalid Passphrase').querySelector('.sub-message').innerHTML = 'The maximum length of the passphrase is 64 characters.';
|
ui.errorMessage('Invalid Passphrase').querySelector('.sub-message').innerHTML = 'The maximum length of the passphrase is 64 characters.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.keepSavedPassphrase(obj.ssid, obj.passphrase)) delete obj.passphrase;
|
||||||
|
|
||||||
|
|
||||||
let overlay = ui.waitMessage(document.getElementById('divNetAdapter'));
|
let overlay = ui.waitMessage(document.getElementById('divNetAdapter'));
|
||||||
|
|
@ -4364,12 +4384,19 @@ class MQTT {
|
||||||
else {
|
else {
|
||||||
console.log(settings);
|
console.log(settings);
|
||||||
ui.toElement(document.getElementById('divMQTT'), { mqtt: settings });
|
ui.toElement(document.getElementById('divMQTT'), { mqtt: settings });
|
||||||
|
// The device never returns the stored password; leave the field blank to keep it.
|
||||||
|
this.savedUsername = settings.username;
|
||||||
|
let fldPassword = document.getElementById('fldMqttPassword');
|
||||||
|
fldPassword.value = '';
|
||||||
|
fldPassword.placeholder = settings.hasPassword ? 'Saved (leave blank to keep)' : 'Password';
|
||||||
document.getElementById('divDiscoveryTopic').style.display = settings.pubDisco ? '' : 'none';
|
document.getElementById('divDiscoveryTopic').style.display = settings.pubDisco ? '' : 'none';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
connectMQTT() {
|
connectMQTT() {
|
||||||
let obj = ui.fromElement(document.getElementById('divMQTT'));
|
let obj = ui.fromElement(document.getElementById('divMQTT'));
|
||||||
|
// A blank password keeps the stored one unless the username changed.
|
||||||
|
if (obj.mqtt.password === '' && obj.mqtt.username === this.savedUsername) delete obj.mqtt.password;
|
||||||
console.log(obj);
|
console.log(obj);
|
||||||
if (obj.mqtt.enabled) {
|
if (obj.mqtt.enabled) {
|
||||||
if (typeof obj.mqtt.hostname !== 'string' || obj.mqtt.hostname.length === 0) {
|
if (typeof obj.mqtt.hostname !== 'string' || obj.mqtt.hostname.length === 0) {
|
||||||
|
|
|
||||||
|
|
@ -105,3 +105,39 @@ It needs a group whose flags are out of date at the moment a client reads it, fo
|
||||||
|
|
||||||
- [ADR-0002](adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md): the loop task is the only writer of the shade model.
|
- [ADR-0002](adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md): the loop task is the only writer of the shade model.
|
||||||
- [Large websocket events are dropped](#large-websocket-events-are-dropped): the same `g_response` buffer.
|
- [Large websocket events are dropped](#large-websocket-events-are-dropped): the same `g_response` buffer.
|
||||||
|
|
||||||
|
## A saved WiFi passphrase or MQTT password can't be cleared from the UI
|
||||||
|
|
||||||
|
**Status:** open. A workaround exists (below).
|
||||||
|
**Introduced:** 2026-10-18, when the device stopped sending stored secrets back to clients.
|
||||||
|
**Location:** `applySecret` in [src/Secrets.h](../src/Secrets.h), called from `WifiSettings::fromJSON` and `MQTTSettings::fromJSON` in [src/ConfigSettings.cpp](../src/ConfigSettings.cpp) and from `/connectwifi`. The UI side is `keepSavedPassphrase` in `Wifi` and `connectMQTT` in `MQTT`, in [data-src/index.js](../data-src/index.js).
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
The settings routes report only whether a secret is stored (`hasPassphrase`, `hasPassword`), so the UI loads every secret field empty. The firmware decides what an empty secret in a request means, so that every UI version is safe, including one older than this change, which sends `""` for the field it can no longer fill in:
|
||||||
|
|
||||||
|
| Secret | Key absent | `""` | A value |
|
||||||
|
|---|---|---|---|
|
||||||
|
| WiFi passphrase | keep | keep, or clear if the same request changes the SSID | replace |
|
||||||
|
| MQTT password | keep | keep, or clear if the same request changes the username | replace |
|
||||||
|
| Login password, PIN | keep | keep | replace |
|
||||||
|
|
||||||
|
Without "empty keeps", saving any other setting from an old UI would wipe the secret: for WiFi the device would reboot and drop into AP mode.
|
||||||
|
|
||||||
|
The cost: an empty value can't mean "no password" while the SSID or username stays the same. Switching to a different open network works.
|
||||||
|
|
||||||
|
The login password and PIN aren't affected. Neither can be empty, and security is turned off with the type selector.
|
||||||
|
|
||||||
|
### Concrete failure modes
|
||||||
|
|
||||||
|
1. The network the device is on is changed to open on the router, keeping its SSID. Saving the network page with a blank passphrase keeps the old one. When a passphrase is set, `WiFi.begin` won't accept anything weaker than WPA2-PSK, so the device can't join the now-open network.
|
||||||
|
2. The MQTT broker stops requiring a password but keeps the username. The device keeps sending the old password.
|
||||||
|
|
||||||
|
### Workaround
|
||||||
|
|
||||||
|
- **MQTT:** save once with a different username, then change it back and save again with the password blank.
|
||||||
|
- **WiFi:** there's no safe workaround. Saving a different SSID makes the device reboot and try to join that network.
|
||||||
|
|
||||||
|
### Fix options
|
||||||
|
|
||||||
|
- **(a) "Clear saved password" checkbox** next to the WiFi passphrase and MQTT password fields, shown only when a value is stored. Since `""` means keep, ticking it sends an explicit flag (`clearPassphrase` / `clearPassword`), which `fromJSON` turns into `applySecret(..., identityChanged = true)`.
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
#include <Preferences.h>
|
#include <Preferences.h>
|
||||||
#include "ConfigSettings.h"
|
#include "ConfigSettings.h"
|
||||||
#include "Utils.h"
|
#include "Utils.h"
|
||||||
|
#include "Secrets.h"
|
||||||
#include "esp_chip_info.h"
|
#include "esp_chip_info.h"
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
|
|
||||||
|
|
@ -301,7 +302,7 @@ bool MQTTSettings::toJSON(JsonObject &obj) {
|
||||||
obj["hostname"] = this->hostname;
|
obj["hostname"] = this->hostname;
|
||||||
obj["port"] = this->port;
|
obj["port"] = this->port;
|
||||||
obj["username"] = this->username;
|
obj["username"] = this->username;
|
||||||
obj["password"] = this->password;
|
obj["hasPassword"] = hasSecret(this->password);
|
||||||
obj["rootTopic"] = this->rootTopic;
|
obj["rootTopic"] = this->rootTopic;
|
||||||
obj["discoTopic"] = this->discoTopic;
|
obj["discoTopic"] = this->discoTopic;
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -311,8 +312,11 @@ bool MQTTSettings::fromJSON(JsonObject &obj) {
|
||||||
if(!obj["pubDisco"].isNull()) this->pubDisco = obj["pubDisco"];
|
if(!obj["pubDisco"].isNull()) this->pubDisco = obj["pubDisco"];
|
||||||
this->parseValueString(obj, "protocol", this->protocol, sizeof(this->protocol));
|
this->parseValueString(obj, "protocol", this->protocol, sizeof(this->protocol));
|
||||||
this->parseValueString(obj, "hostname", this->hostname, sizeof(this->hostname));
|
this->parseValueString(obj, "hostname", this->hostname, sizeof(this->hostname));
|
||||||
|
// An empty password clears the stored one only alongside a new username.
|
||||||
|
const char *username = obj["username"].as<const char *>();
|
||||||
|
bool newUsername = username && strcmp(username, this->username) != 0;
|
||||||
this->parseValueString(obj, "username", this->username, sizeof(this->username));
|
this->parseValueString(obj, "username", this->username, sizeof(this->username));
|
||||||
this->parseValueString(obj, "password", this->password, sizeof(this->password));
|
applySecret(obj["password"].as<const char *>(), this->password, sizeof(this->password), newUsername);
|
||||||
this->parseValueString(obj, "rootTopic", this->rootTopic, sizeof(this->rootTopic));
|
this->parseValueString(obj, "rootTopic", this->rootTopic, sizeof(this->rootTopic));
|
||||||
this->parseValueString(obj, "discoTopic", this->discoTopic, sizeof(this->discoTopic));
|
this->parseValueString(obj, "discoTopic", this->discoTopic, sizeof(this->discoTopic));
|
||||||
if(!obj["port"].isNull()) this->port = obj["port"];
|
if(!obj["port"].isNull()) this->port = obj["port"];
|
||||||
|
|
@ -478,16 +482,16 @@ bool SecuritySettings::begin() {
|
||||||
bool SecuritySettings::fromJSON(JsonObject &obj) {
|
bool SecuritySettings::fromJSON(JsonObject &obj) {
|
||||||
if(!obj["type"].isNull()) this->type = static_cast<security_types>(obj["type"].as<uint8_t>());
|
if(!obj["type"].isNull()) this->type = static_cast<security_types>(obj["type"].as<uint8_t>());
|
||||||
this->parseValueString(obj, "username", this->username, sizeof(this->username));
|
this->parseValueString(obj, "username", this->username, sizeof(this->username));
|
||||||
this->parseValueString(obj, "password", this->password, sizeof(this->password));
|
applySecret(obj["password"].as<const char *>(), this->password, sizeof(this->password));
|
||||||
this->parseValueString(obj, "pin", this->pin, sizeof(this->pin));
|
applySecret(obj["pin"].as<const char *>(), this->pin, sizeof(this->pin));
|
||||||
if(!obj["permissions"].isNull()) this->permissions = obj["permissions"];
|
if(!obj["permissions"].isNull()) this->permissions = obj["permissions"];
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
bool SecuritySettings::toJSON(JsonObject &obj) {
|
bool SecuritySettings::toJSON(JsonObject &obj) {
|
||||||
obj["type"] = static_cast<uint8_t>(this->type);
|
obj["type"] = static_cast<uint8_t>(this->type);
|
||||||
obj["username"] = this->username;
|
obj["username"] = this->username;
|
||||||
obj["password"] = this->password;
|
obj["hasPassword"] = hasSecret(this->password);
|
||||||
obj["pin"] = this->pin;
|
obj["hasPin"] = hasSecret(this->pin);
|
||||||
obj["permissions"] = this->permissions;
|
obj["permissions"] = this->permissions;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -516,7 +520,7 @@ bool SecuritySettings::load() {
|
||||||
}
|
}
|
||||||
void SecuritySettings::print() {
|
void SecuritySettings::print() {
|
||||||
ESP_LOGD(TAG, "SECURITY Type:%u Username:[%s] Password:[%s] Pin:[%s] Permissions:%u",
|
ESP_LOGD(TAG, "SECURITY Type:%u Username:[%s] Password:[%s] Pin:[%s] Permissions:%u",
|
||||||
static_cast<uint8_t>(this->type), this->username, this->password, this->pin, this->permissions);
|
static_cast<uint8_t>(this->type), this->username, maskSecret(this->password), maskSecret(this->pin), this->permissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
WifiSettings::WifiSettings() {}
|
WifiSettings::WifiSettings() {}
|
||||||
|
|
@ -525,15 +529,18 @@ bool WifiSettings::begin() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
bool WifiSettings::fromJSON(JsonObject &obj) {
|
bool WifiSettings::fromJSON(JsonObject &obj) {
|
||||||
|
// An empty passphrase clears the stored one only alongside a new SSID (an open network).
|
||||||
|
const char *ssid = obj["ssid"].as<const char *>();
|
||||||
|
bool newSsid = ssid && strcmp(ssid, this->ssid) != 0;
|
||||||
this->parseValueString(obj, "ssid", this->ssid, sizeof(this->ssid));
|
this->parseValueString(obj, "ssid", this->ssid, sizeof(this->ssid));
|
||||||
this->parseValueString(obj, "passphrase", this->passphrase, sizeof(this->passphrase));
|
applySecret(obj["passphrase"].as<const char *>(), this->passphrase, sizeof(this->passphrase), newSsid);
|
||||||
if(!obj["roaming"].isNull()) this->roaming = obj["roaming"];
|
if(!obj["roaming"].isNull()) this->roaming = obj["roaming"];
|
||||||
if(!obj["hidden"].isNull()) this->hidden = obj["hidden"];
|
if(!obj["hidden"].isNull()) this->hidden = obj["hidden"];
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
bool WifiSettings::toJSON(JsonObject &obj) {
|
bool WifiSettings::toJSON(JsonObject &obj) {
|
||||||
obj["ssid"] = this->ssid;
|
obj["ssid"] = this->ssid;
|
||||||
obj["passphrase"] = this->passphrase;
|
obj["hasPassphrase"] = hasSecret(this->passphrase);
|
||||||
obj["roaming"] = this->roaming;
|
obj["roaming"] = this->roaming;
|
||||||
obj["hidden"] = this->hidden;
|
obj["hidden"] = this->hidden;
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -579,7 +586,7 @@ String WifiSettings::mapEncryptionType(int type) {
|
||||||
return "Unknown";
|
return "Unknown";
|
||||||
}
|
}
|
||||||
void WifiSettings::print() {
|
void WifiSettings::print() {
|
||||||
ESP_LOGD(TAG, "WIFI Settings SSID: [%s] PassPhrase: [%s]", this->ssid, this->passphrase);
|
ESP_LOGD(TAG, "WIFI Settings SSID: [%s] PassPhrase: [%s]", this->ssid, maskSecret(this->passphrase));
|
||||||
}
|
}
|
||||||
void WifiSettings::printNetworks() {
|
void WifiSettings::printNetworks() {
|
||||||
int n = WiFi.scanNetworks(false, false);
|
int n = WiFi.scanNetworks(false, false);
|
||||||
|
|
|
||||||
30
src/Secrets.h
Normal file
30
src/Secrets.h
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
#ifndef secrets_h
|
||||||
|
#define secrets_h
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
// Stored secrets (WiFi passphrase, MQTT password, login password/PIN) are
|
||||||
|
// write-only: responses carry hasSecret() flags and logs carry maskSecret().
|
||||||
|
|
||||||
|
inline bool hasSecret(const char *s) { return s && s[0] != '\0'; }
|
||||||
|
|
||||||
|
inline const char *maskSecret(const char *s) { return hasSecret(s) ? "<set>" : "<empty>"; }
|
||||||
|
|
||||||
|
// Updates dest from an incoming value, truncating to size. Pass
|
||||||
|
// obj["key"].as<const char *>(), which is null when the key is absent.
|
||||||
|
// absent -> keep the stored secret.
|
||||||
|
// "" -> keep it too, unless identityChanged (a new SSID or username in
|
||||||
|
// the same request), then clear it. UIs older than write-only
|
||||||
|
// secrets send "" for a field the device no longer fills in.
|
||||||
|
// value -> replace it.
|
||||||
|
// Returns true when dest changed.
|
||||||
|
inline bool applySecret(const char *incoming, char *dest, size_t size, bool identityChanged = false) {
|
||||||
|
if(!incoming || size == 0) return false;
|
||||||
|
if(incoming[0] == '\0' && !identityChanged) return false;
|
||||||
|
size_t len = strlen(incoming);
|
||||||
|
if(len >= size) len = size - 1;
|
||||||
|
if(strncmp(dest, incoming, len) == 0 && dest[len] == '\0') return false;
|
||||||
|
memcpy(dest, incoming, len);
|
||||||
|
dest[len] = '\0';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
@ -125,7 +125,6 @@ bool Web::createAPIToken(const char *payload, char *token) {
|
||||||
sprintf(str, "%02x", (int)hmacResult[i]);
|
sprintf(str, "%02x", (int)hmacResult[i]);
|
||||||
strcat(token, str);
|
strcat(token, str);
|
||||||
}
|
}
|
||||||
ESP_LOGD(TAG, "Hash: %s", token);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
bool Web::createAPIToken(const IPAddress ipAddress, char *token) {
|
bool Web::createAPIToken(const IPAddress ipAddress, char *token) {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
#include "ConfigSettings.h"
|
#include "ConfigSettings.h"
|
||||||
#include "ConfigFile.h"
|
#include "ConfigFile.h"
|
||||||
#include "Utils.h"
|
#include "Utils.h"
|
||||||
|
#include "Secrets.h"
|
||||||
#include "SSDP.h"
|
#include "SSDP.h"
|
||||||
#include "Somfy.h"
|
#include "Somfy.h"
|
||||||
#include "WResp.h"
|
#include "WResp.h"
|
||||||
|
|
@ -621,7 +622,6 @@ void registerWebUiRoutes(AsyncWebServer &server) {
|
||||||
resp.beginObject();
|
resp.beginObject();
|
||||||
resp.beginObject("connected");
|
resp.beginObject("connected");
|
||||||
resp.addElem("name", settings.WIFI.ssid);
|
resp.addElem("name", settings.WIFI.ssid);
|
||||||
resp.addElem("passphrase", settings.WIFI.passphrase);
|
|
||||||
resp.addElem("strength", (int32_t)WiFi.RSSI());
|
resp.addElem("strength", (int32_t)WiFi.RSSI());
|
||||||
resp.addElem("channel", (int32_t)WiFi.channel());
|
resp.addElem("channel", (int32_t)WiFi.channel());
|
||||||
resp.endObject();
|
resp.endObject();
|
||||||
|
|
@ -782,16 +782,13 @@ void registerWebUiRoutes(AsyncWebServer &server) {
|
||||||
}
|
}
|
||||||
if(!obj["wifi"].isNull()) {
|
if(!obj["wifi"].isNull()) {
|
||||||
JsonObject objWifi = obj["wifi"];
|
JsonObject objWifi = obj["wifi"];
|
||||||
if(settings.connType == conn_types_t::wifi) {
|
// Compared after fromJSON, which decides whether an empty passphrase keeps the stored one.
|
||||||
if(!objWifi["ssid"].isNull() && objWifi["ssid"].as<String>().compareTo(settings.WIFI.ssid) != 0) {
|
WifiSettings before = settings.WIFI;
|
||||||
if(WiFi.softAPgetStationNum() == 0) reboot = true;
|
|
||||||
}
|
|
||||||
if(!objWifi["passphrase"].isNull() && objWifi["passphrase"].as<String>().compareTo(settings.WIFI.passphrase) != 0) {
|
|
||||||
if(WiFi.softAPgetStationNum() == 0) reboot = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
settings.WIFI.fromJSON(objWifi);
|
settings.WIFI.fromJSON(objWifi);
|
||||||
settings.WIFI.save();
|
settings.WIFI.save();
|
||||||
|
if(settings.connType == conn_types_t::wifi && WiFi.softAPgetStationNum() == 0 &&
|
||||||
|
(strcmp(before.ssid, settings.WIFI.ssid) != 0 || strcmp(before.passphrase, settings.WIFI.passphrase) != 0))
|
||||||
|
reboot = true;
|
||||||
}
|
}
|
||||||
if(!obj["ethernet"].isNull()) {
|
if(!obj["ethernet"].isNull()) {
|
||||||
JsonObject objEth = obj["ethernet"];
|
JsonObject objEth = obj["ethernet"];
|
||||||
|
|
@ -834,18 +831,19 @@ void registerWebUiRoutes(AsyncWebServer &server) {
|
||||||
JsonObject obj = json.as<JsonObject>();
|
JsonObject obj = json.as<JsonObject>();
|
||||||
ESP_LOGD(TAG, "Settings WIFI connection...");
|
ESP_LOGD(TAG, "Settings WIFI connection...");
|
||||||
String ssid = "";
|
String ssid = "";
|
||||||
String passphrase = "";
|
|
||||||
if(!obj["ssid"].isNull()) ssid = obj["ssid"].as<String>();
|
if(!obj["ssid"].isNull()) ssid = obj["ssid"].as<String>();
|
||||||
if(!obj["passphrase"].isNull()) passphrase = obj["passphrase"].as<String>();
|
bool newSsid = ssid.compareTo(settings.WIFI.ssid) != 0;
|
||||||
bool reboot = false;
|
// Clients never receive the stored passphrase: an omitted or empty one keeps it unless the SSID changes.
|
||||||
if(ssid.compareTo(settings.WIFI.ssid) != 0) reboot = true;
|
char passphrase[sizeof(settings.WIFI.passphrase)];
|
||||||
if(passphrase.compareTo(settings.WIFI.passphrase) != 0) reboot = true;
|
SETCHARPROP(passphrase, settings.WIFI.passphrase, sizeof(passphrase));
|
||||||
|
bool newPassphrase = applySecret(obj["passphrase"].as<const char *>(), passphrase, sizeof(passphrase), newSsid);
|
||||||
|
bool reboot = newSsid || newPassphrase;
|
||||||
if(!settings.WIFI.ssidExists(ssid.c_str()) && ssid.length() > 0) {
|
if(!settings.WIFI.ssidExists(ssid.c_str()) && ssid.length() > 0) {
|
||||||
request->send(400, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"WiFi Network Does not exist\"}");
|
request->send(400, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"WiFi Network Does not exist\"}");
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
SETCHARPROP(settings.WIFI.ssid, ssid.c_str(), sizeof(settings.WIFI.ssid));
|
SETCHARPROP(settings.WIFI.ssid, ssid.c_str(), sizeof(settings.WIFI.ssid));
|
||||||
SETCHARPROP(settings.WIFI.passphrase, passphrase.c_str(), sizeof(settings.WIFI.passphrase));
|
SETCHARPROP(settings.WIFI.passphrase, passphrase, sizeof(settings.WIFI.passphrase));
|
||||||
settings.WIFI.save();
|
settings.WIFI.save();
|
||||||
settings.WIFI.print();
|
settings.WIFI.print();
|
||||||
request->send(201, _encoding_json, "{\"status\":\"OK\",\"desc\":\"Successfully set server connection\"}");
|
request->send(201, _encoding_json, "{\"status\":\"OK\",\"desc\":\"Successfully set server connection\"}");
|
||||||
|
|
|
||||||
106
test/test_secrets/test_secrets.cpp
Normal file
106
test/test_secrets/test_secrets.cpp
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
#include <unity.h>
|
||||||
|
#include "Secrets.h"
|
||||||
|
|
||||||
|
void setUp() {}
|
||||||
|
void tearDown() {}
|
||||||
|
|
||||||
|
/*********************************************************************
|
||||||
|
* hasSecret / maskSecret
|
||||||
|
********************************************************************/
|
||||||
|
static void test_hasSecret_null_and_empty_are_unset() {
|
||||||
|
TEST_ASSERT_FALSE(hasSecret(nullptr));
|
||||||
|
TEST_ASSERT_FALSE(hasSecret(""));
|
||||||
|
}
|
||||||
|
static void test_hasSecret_any_value_is_set() {
|
||||||
|
TEST_ASSERT_TRUE(hasSecret("x"));
|
||||||
|
TEST_ASSERT_TRUE(hasSecret(" "));
|
||||||
|
}
|
||||||
|
static void test_maskSecret_never_returns_the_secret() {
|
||||||
|
TEST_ASSERT_EQUAL_STRING("<set>", maskSecret("hunter2"));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("<empty>", maskSecret(""));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("<empty>", maskSecret(nullptr));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*********************************************************************
|
||||||
|
* applySecret
|
||||||
|
********************************************************************/
|
||||||
|
static void test_applySecret_absent_keeps_stored_value() {
|
||||||
|
char dest[16] = "stored";
|
||||||
|
TEST_ASSERT_FALSE(applySecret(nullptr, dest, sizeof(dest)));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("stored", dest);
|
||||||
|
}
|
||||||
|
static void test_applySecret_new_value_replaces_stored() {
|
||||||
|
char dest[16] = "stored";
|
||||||
|
TEST_ASSERT_TRUE(applySecret("fresh", dest, sizeof(dest)));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("fresh", dest);
|
||||||
|
}
|
||||||
|
static void test_applySecret_same_value_is_no_change() {
|
||||||
|
char dest[16] = "stored";
|
||||||
|
TEST_ASSERT_FALSE(applySecret("stored", dest, sizeof(dest)));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("stored", dest);
|
||||||
|
}
|
||||||
|
static void test_applySecret_prefix_of_stored_is_a_change() {
|
||||||
|
char dest[16] = "stored";
|
||||||
|
TEST_ASSERT_TRUE(applySecret("sto", dest, sizeof(dest)));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("sto", dest);
|
||||||
|
}
|
||||||
|
// An older UI sends "" for a field the device no longer fills in.
|
||||||
|
static void test_applySecret_empty_value_keeps_stored() {
|
||||||
|
char dest[16] = "stored";
|
||||||
|
TEST_ASSERT_FALSE(applySecret("", dest, sizeof(dest)));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("stored", dest);
|
||||||
|
}
|
||||||
|
// A new SSID or username with an empty secret, e.g. an open WiFi network.
|
||||||
|
static void test_applySecret_empty_value_with_new_identity_clears_stored() {
|
||||||
|
char dest[16] = "stored";
|
||||||
|
TEST_ASSERT_TRUE(applySecret("", dest, sizeof(dest), true));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("", dest);
|
||||||
|
}
|
||||||
|
static void test_applySecret_empty_value_with_new_identity_and_nothing_stored() {
|
||||||
|
char dest[16] = "";
|
||||||
|
TEST_ASSERT_FALSE(applySecret("", dest, sizeof(dest), true));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("", dest);
|
||||||
|
}
|
||||||
|
static void test_applySecret_new_identity_still_takes_a_value() {
|
||||||
|
char dest[16] = "stored";
|
||||||
|
TEST_ASSERT_TRUE(applySecret("fresh", dest, sizeof(dest), true));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("fresh", dest);
|
||||||
|
}
|
||||||
|
static void test_applySecret_absent_keeps_stored_even_with_new_identity() {
|
||||||
|
char dest[16] = "stored";
|
||||||
|
TEST_ASSERT_FALSE(applySecret(nullptr, dest, sizeof(dest), true));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("stored", dest);
|
||||||
|
}
|
||||||
|
static void test_applySecret_truncates_and_terminates() {
|
||||||
|
char storage[8];
|
||||||
|
memset(storage, 'X', sizeof(storage));
|
||||||
|
storage[0] = '\0';
|
||||||
|
TEST_ASSERT_TRUE(applySecret("abcdefgh", storage, 5));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("abcd", storage);
|
||||||
|
// Nothing past the buffer size is touched.
|
||||||
|
TEST_ASSERT_EQUAL_HEX8('X', storage[5]);
|
||||||
|
}
|
||||||
|
static void test_applySecret_truncated_same_value_is_no_change() {
|
||||||
|
char dest[5] = "abcd";
|
||||||
|
TEST_ASSERT_FALSE(applySecret("abcdefgh", dest, sizeof(dest)));
|
||||||
|
TEST_ASSERT_EQUAL_STRING("abcd", dest);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char **argv) {
|
||||||
|
UNITY_BEGIN();
|
||||||
|
RUN_TEST(test_hasSecret_null_and_empty_are_unset);
|
||||||
|
RUN_TEST(test_hasSecret_any_value_is_set);
|
||||||
|
RUN_TEST(test_maskSecret_never_returns_the_secret);
|
||||||
|
RUN_TEST(test_applySecret_absent_keeps_stored_value);
|
||||||
|
RUN_TEST(test_applySecret_new_value_replaces_stored);
|
||||||
|
RUN_TEST(test_applySecret_same_value_is_no_change);
|
||||||
|
RUN_TEST(test_applySecret_prefix_of_stored_is_a_change);
|
||||||
|
RUN_TEST(test_applySecret_empty_value_keeps_stored);
|
||||||
|
RUN_TEST(test_applySecret_empty_value_with_new_identity_clears_stored);
|
||||||
|
RUN_TEST(test_applySecret_empty_value_with_new_identity_and_nothing_stored);
|
||||||
|
RUN_TEST(test_applySecret_new_identity_still_takes_a_value);
|
||||||
|
RUN_TEST(test_applySecret_absent_keeps_stored_even_with_new_identity);
|
||||||
|
RUN_TEST(test_applySecret_truncates_and_terminates);
|
||||||
|
RUN_TEST(test_applySecret_truncated_same_value_is_no_change);
|
||||||
|
return UNITY_END();
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue