From a0edea19e1cdb8dfbc40d56335fe3ba0348639ec Mon Sep 17 00:00:00 2001 From: k3ldar Date: Sat, 1 Aug 2026 18:02:03 +0200 Subject: [PATCH] Implement network authentication --- Docs/Commands.md | 61 +- ...G_SYNC_DIAGRAM.md => ConfigSyncDiagram.md} | 0 ...FERENCE.md => ConfigSyncQuickReference.md} | 0 ...FIG_SYNC_README.md => ConfigSyncReadme.md} | 0 Docs/NetworkAuth.md | 389 +++++++++++ ...ION.md => SDCardCallbackImplementation.md} | 0 ...CONFIG_README.md => SDCardConfigReadme.md} | 0 ...LEMENTATION.md => SDCardImplementation.md} | 0 ...TATION.md => SyncWarningImplementation.md} | 0 PowerControlHub/ConfigCommandHandler.cpp | 77 +++ PowerControlHub/ConfigController.cpp | 102 ++- PowerControlHub/ConfigController.h | 6 + PowerControlHub/ConfigNetworkHandler.cpp | 94 +++ PowerControlHub/SystemDefinitions.h | 8 + PowerControlHub/SystemFunctions.cpp | 49 ++ PowerControlHub/SystemFunctions.h | 11 + PowerControlHub/WifiController.h | 3 + PowerControlHub/WifiServer.cpp | 602 +++++++++++------- PowerControlHub/WifiServer.h | 4 + PowerControlHubApp/AppShell.xaml.cs | 1 + PowerControlHubApp/Internal/Constants.cs | 18 + PowerControlHubApp/MauiProgram.cs | 15 +- .../Messages/AuthConfigChanged.cs | 5 + .../Models/Json/AuthConfigModel.cs | 16 + PowerControlHubApp/PowerControlHubApp.csproj | 3 + .../Services/ConfigConnection.cs | 114 +++- PowerControlHubApp/Services/ConfigPoller.cs | 10 + .../Services/DashboardConnection.cs | 10 +- .../Services/DeviceAuthHandler.cs | 142 +++++ .../Services/IConfigConnection.cs | 5 + .../Services/PowerHubService.cs | 47 +- .../ViewModels/NetworkSecurityViewModel.cs | 241 +++++++ .../ViewModels/SettingsViewModel.cs | 33 +- .../ViewModels/SystemViewModel.cs | 3 + .../Views/NetworkSecurityPage.xaml | 121 ++++ .../Views/NetworkSecurityPage.xaml.cs | 21 + PowerControlHubApp/Views/SettingsPage.xaml | 45 +- PowerControlHubApp/Views/SystemPage.xaml | 14 +- 38 files changed, 2015 insertions(+), 255 deletions(-) rename Docs/{CONFIG_SYNC_DIAGRAM.md => ConfigSyncDiagram.md} (100%) rename Docs/{CONFIG_SYNC_QUICK_REFERENCE.md => ConfigSyncQuickReference.md} (100%) rename Docs/{CONFIG_SYNC_README.md => ConfigSyncReadme.md} (100%) create mode 100644 Docs/NetworkAuth.md rename Docs/{SD_CARD_CALLBACK_IMPLEMENTATION.md => SDCardCallbackImplementation.md} (100%) rename Docs/{SD_CONFIG_README.md => SDCardConfigReadme.md} (100%) rename Docs/{SD_CONFIG_IMPLEMENTATION.md => SDCardImplementation.md} (100%) rename Docs/{SYNC_WARNING_IMPLEMENTATION.md => SyncWarningImplementation.md} (100%) create mode 100644 PowerControlHubApp/Messages/AuthConfigChanged.cs create mode 100644 PowerControlHubApp/Models/Json/AuthConfigModel.cs create mode 100644 PowerControlHubApp/Services/DeviceAuthHandler.cs create mode 100644 PowerControlHubApp/ViewModels/NetworkSecurityViewModel.cs create mode 100644 PowerControlHubApp/Views/NetworkSecurityPage.xaml create mode 100644 PowerControlHubApp/Views/NetworkSecurityPage.xaml.cs diff --git a/Docs/Commands.md b/Docs/Commands.md index 59bd238..19dbe9f 100644 --- a/Docs/Commands.md +++ b/Docs/Commands.md @@ -100,7 +100,7 @@ Example: `GET /api/system/F2` | `C16` — WiFi Connection State | `C16` | Returns current `WifiConnectionState` value. Read-only, no params. | | `C17` — WiFi AP IP Address | `C17:192.168.4.1` | Set AP-mode IP address. Value directly, no `v=` prefix. | | `C18` — RTC Pins | `C18:dat=4;clk=5;rst=6` | Set DS1302 RTC pins. Use `255` for any pin not fitted. Call `C0` to persist. | -| ~~`C19`~~ | — | **Retired.** Use `N` commands (Nextion Display Configuration) instead. | +| `C19` — Network Authentication | `C19:e=1;k=MyApiKey;h=MyHmacKey` or `C19:g=1` | Configure WiFi API authentication. **Params:** `e=<0\|1>` enable/disable auth (disabled by default). `k=` set API key (max 31 chars). `h=` set HMAC-SHA256 key (max 31 chars). `g=1` auto-generate device-unique keys from the WiFi MAC address. No params returns current state as `e=<0\|1>;k=;h=`. Call `C0` to persist. When enabled, all `/api/*` endpoints require either an `X-API-Key` header matching the configured key, or valid `X-Auth-Timestamp` + `X-Auth-Signature` HMAC headers. See [Network Authentication](#network-authentication) below. | | `C20` — Timezone Offset | `C20:v=-5` | Set UTC timezone offset in hours. Valid range: −12 to +14. | | `C21` — MMSI | `C21:123456789` | Set 9-digit Maritime Mobile Service Identity. Value directly. | | `C22` — Call Sign | `C22:ABCD123` | Set location call sign. Value directly, truncated to max length. | @@ -117,7 +117,7 @@ Example: `GET /api/system/F2` | `C33` — Light Sensor Threshold | `C33:v=512` | Analogue threshold (0–1023) above which daytime is detected. Default 512. Uses a rolling average of the last 10 samples; 3 consecutive consistent readings required before day/night state changes (see `S16`). | **C1 response sequence:** -PCH sends back: `C3` (name), `C4` (SPI pins), `C5` (home-button mappings), `C7` (location type), `C9` (sound delay), `C10`–`C17` (WiFi/Bluetooth — PCH only), `C18` (RTC pins), `C20`–`C23` (location identity), `C24`–`C27` (LED config), `C28` (tones), `N1`–`N6` (Nextion config), `R6` per relay (names), `R7` per relay (colours), `R8` per relay (default states), `R9` (linked pairs), `R10` (action types), `R11` (pins), `S0` per sensor (sensor config), then `ACK:C1=ok`. +PCH sends back: `C3` (name), `C4` (SPI pins), `C5` (home-button mappings), `C7` (location type), `C9` (sound delay), `C10`–`C17` (WiFi/Bluetooth — PCH only), `C18` (RTC pins), `C19` (network auth), `C20`–`C23` (location identity), `C24`–`C27` (LED config), `C28` (tones), `N1`–`N6` (Nextion config), `R6` per relay (names), `R7` per relay (colours), `R8` per relay (default states), `R9` (linked pairs), `R10` (action types), `R11` (pins), `S0` per sensor (sensor config), then `ACK:C1=ok`. Common errors: `Missing param`, `Empty name`, `Invalid boat type`, `Invalid offset (-12 to +14)`, `MMSI must be 9 digits`, `Invalid speed (4, 8, 12, 16, 20, or 24 MHz)`, `Config not available`, `EEPROM commit failed`. @@ -524,6 +524,63 @@ Example: `GET /api/timer/T0` --- +## Network Authentication + +When network auth is enabled (`C19:e=1`), every request to `/api/*` endpoints must include authentication credentials. Two authentication methods are supported; clients may use either one. + +### API Key (simple) + +Include the `X-API-Key` header with the value of the configured API key: + +```bash +curl -H "X-API-Key: ak-sfb-A1B2C3" http://DEVICE/api/system/F7 +``` + +### HMAC-SHA256 (strong) + +Include two headers: `X-Auth-Timestamp` (Unix epoch seconds) and `X-Auth-Signature` (lowercase hex HMAC-SHA256 of the canonical request). + +**Canonical signing input format:** +``` +\n\n\n +``` + +- `timestamp` — same value as `X-Auth-Timestamp` +- `METHOD` — HTTP method: `GET`, `POST`, etc. +- `path` — request path, e.g. `/api/relay/R3` +- `body` — raw request body for POST; empty string for GET + +**Example (Bash):** +```bash +TS=$(date +%s) +PATH="/api/system/F7" +METHOD="GET" +BODY="" +HMAC_KEY="hk-sfb-A1B2C3" + +SIGN_INPUT="${TS}\n${METHOD}\n${PATH}\n${BODY}" +SIG=$(echo -ne "$SIGN_INPUT" | openssl dgst -sha256 -hmac "$HMAC_KEY" | awk '{print $NF}') + +curl -H "X-Auth-Timestamp: ${TS}" -H "X-Auth-Signature: ${SIG}" http://DEVICE${PATH} +``` + +### Timestamp window and clock bootstrap + +- The HMAC timestamp must be within ±300 seconds (5 minutes) of the device's clock. +- **`F6` (Set DateTime) is always permitted via all transports**, even when auth is enabled, as long as the device clock has **never** been synchronized. Once time is set, `F6` requires authentication like all other commands. This prevents a deadlock where the client needs auth to set the clock but the clock is wrong, causing HMAC verification to fail. +- Clients can use an **API Key** to bootstrap the clock via `F6`, then switch to HMAC for stronger ongoing security. + +### Best practices + +1. **Enable auth after initial setup:** `C19:g=1` to generate per-device unique keys, then `C19:e=1` to enable. +2. **Persist changes:** Call `C0` (Save Settings) after configuring auth to survive power cycles. +3. **Synchronize the clock:** Use `F0:w=0x00;t=` heartbeat or `F6:v=` to keep the device clock accurate for HMAC. +4. **Rotate keys periodically:** `C19:k=` and `C19:h=` then `C0` to persist. + +Common errors: `API key too long`, `HMAC key too long`. + +--- + ## Notes - All commands and parameters are case-sensitive. diff --git a/Docs/CONFIG_SYNC_DIAGRAM.md b/Docs/ConfigSyncDiagram.md similarity index 100% rename from Docs/CONFIG_SYNC_DIAGRAM.md rename to Docs/ConfigSyncDiagram.md diff --git a/Docs/CONFIG_SYNC_QUICK_REFERENCE.md b/Docs/ConfigSyncQuickReference.md similarity index 100% rename from Docs/CONFIG_SYNC_QUICK_REFERENCE.md rename to Docs/ConfigSyncQuickReference.md diff --git a/Docs/CONFIG_SYNC_README.md b/Docs/ConfigSyncReadme.md similarity index 100% rename from Docs/CONFIG_SYNC_README.md rename to Docs/ConfigSyncReadme.md diff --git a/Docs/NetworkAuth.md b/Docs/NetworkAuth.md new file mode 100644 index 0000000..fe1ca4b --- /dev/null +++ b/Docs/NetworkAuth.md @@ -0,0 +1,389 @@ +# NetworkAuth — WiFi API Authentication + +NetworkAuth is a dual-mode authentication layer that protects the device's HTTP API from unauthorised access over WiFi. It is disabled by default and must be explicitly enabled via the serial console, Bluetooth, or the MAUI application after initial configuration. + +--- + +## Why It Exists + +The ESP32 HTTP server exposes every control surface of the device — relay toggling, sensor data, pin configuration, MQTT credentials, and firmware updates — as REST endpoints at well-known paths. Without authentication: + +- Any device on the same WiFi network can toggle relays, change pin assignments, or trigger OTA updates. +- The `/api/index` response includes the API key and HMAC key in cleartext, allowing an attacker to harvest credentials and impersonate the authorised MAUI client. +- Browser-based discovery (e.g. Chrome typing `192.168.4.1/api/index`) immediately leaks all device state and secrets. + +NetworkAuth gates every HTTP request (including `/api/index`) behind credential verification. Requests lacking valid credentials receive `401 Unauthorized`. + +--- + +## How It Works + +### Server-Side Enforcement + +Auth is enforced inside `WifiServer::processClientRequest()` in `WifiServer.cpp`. The check runs **before** any route handler is dispatched: + +```cpp +if (_authConfig != nullptr && _authConfig->enabled) +{ + // parse X-API-Key, X-Auth-Timestamp, X-Auth-Signature headers + // validate API key match OR HMAC signature + if (!authorized) + { + sendResponse(401, …); + cleanupClient(index); + return; + } +} +``` + +If `_authConfig` is `nullptr` or `enabled == false`, the check is skipped and all requests are serviced without credentials. + +The `_authConfig` pointer is set once at startup from the global `Config` struct (stored in EEPROM) via `WifiServer::setAuthConfig()`. It points to the live in-memory config, so changes take effect immediately after a save (no reboot required). + +### Credential Storage + +Auth credentials live in `Config.h` as part of the persistent `Config` struct: + +```cpp +struct NetworkAuthConfig { + bool enabled; // master switch + uint8_t version; // reserved + char apiKey[32]; // "ak-XXXX…" — 31 chars + null + char hmacKey[32]; // "hk-XXXX…" — 31 chars + null + uint8_t reserved[4]; +} __attribute__((packed)); +``` + +Both keys are 31 characters max, null-terminated. The `version` and `reserved` fields are reserved for future use. + +--- + +## Authentication Mechanisms + +Two independent mechanisms are supported. Either one grants access. + +### 1. API Key (Simple) + +**Header:** `X-API-Key: ak-pch-1712A0` + +The server performs an exact string comparison against `_authConfig->apiKey`. If they match, the request is authorised. + +This is the simplest method. It requires no timestamp or body hashing and works even when the device RTC has not been set. It is less secure than HMAC because the key is transmitted verbatim on every request and could be captured from HTTP traffic (the connection is plain HTTP, not HTTPS). + +### 2. HMAC-SHA256 (Strong) + +**Headers:** + +| Header | Value | +|---|---| +| `X-Auth-Timestamp` | Unix timestamp (seconds) of the signing moment | +| `X-Auth-Signature` | Lowercase hex-encoded HMAC-SHA256 signature | + +**Signing input format** (canonical, one `\n`-separated line): + +``` +{timestamp}\n{METHOD}\n{path}\n{body} +``` + +Example signing input for `GET /api/index` with no body at timestamp `1746000000`: + +``` +1746000000 +GET +/api/index + +``` + +The signature is computed as `HMAC-SHA256(hmacKey, signInput)` and encoded as lowercase hex. + +**Timestamp window:** ±300 seconds (5 minutes). Requests outside this window are rejected. This requires the device RTC to be set. If `DateTimeManager::isTimeSet()` returns false, HMAC verification is skipped and only API key auth can succeed. + +**Device time synchronisation:** The ESP32 has no battery-backed RTC and loses time on power cycle. The device clock must be set before HMAC authentication will function. Command `F6` sets the system date/time: + +``` +F6;2026-07-15T14:30:00 +``` + +The MAUI app sends `F6` automatically on first connection via the `TimeSyncService`, so HMAC works out of the box after pairing with the app. If connecting from a custom client or script, send `F6` with the current UTC time in ISO 8601 format before issuing HMAC-signed requests. + +**HMAC key format:** `hk-XXXX…` — 31 characters. The key is the HMAC secret shared between client and server. + +### Header Parsing + +Headers are extracted from the raw HTTP request buffer by case-insensitive substring search. Both `X-API-Key:` and `x-api-key:` are recognised. Header values extend to the next `\r\n` or `\n`. + +--- + +## Guarded Endpoints + +When auth is enabled, **all** HTTP endpoints are guarded. This includes: + +| Path | Method | Description | +|---|---|---| +| `/api/index` | GET | Full device state JSON (relays, sensors, config, auth keys) | +| `/api/relay/*` | GET/POST | Relay control and configuration | +| `/api/config/*` | GET/POST | Device configuration (pins, network, auth itself) | +| `/api/sensor/*` | GET/POST | Sensor configuration | +| `/api/sound/*` | GET/POST | Sound/signal control | +| `/api/system/*` | GET/POST | System commands, OTA, time | +| `/api/warning/*` | GET/POST | Warning manager | +| `/api/mqtt/*` | GET/POST | MQTT broker settings | +| `/api/scheduler/*` | GET/POST | Schedule management | +| `/api/externalsensor/*` | GET/POST | External sensor registration | + +The built-in web UI (`/index` — HTML page) is **not** guarded by auth. However, its embedded JavaScript calls `fetch('/api/index')` every 5 seconds to refresh data. When auth is enabled, these fetch calls will receive `401` and the web UI will show stale data. This is by design — the web UI is a convenience dashboard for trusted local networks. For secure operation, use the MAUI application which sends proper auth headers. + +--- + +## Configuration Interface + +### Command `C19` — Network Authentication + +Auth is configured via command `C19` on all transports (serial, Bluetooth, WiFi). + +**Serial / Bluetooth format:** + +``` +C19 — read current state +C19;e=1 — enable auth +C19;e=0 — disable auth +C19;k=ak-newkey — set API key +C19;h=hk-newkey — set HMAC key +C19;g=1 — auto-generate both keys +``` + +**WiFi HTTP format:** + +``` +GET /api/config/C19 — read current state +POST /api/config/C19?e=1 — enable +POST /api/config/C19?k=ak-newkey — set API key +POST /api/config/C19?h=hk-newkey — set HMAC key +POST /api/config/C19?g=1 — auto-generate keys +POST /api/config/C19?e=1&k=ak-X&h=hk-X — combined +``` + +**Read response** (serial): + +``` +ACK:C19:e=1;k=ak-pch-1712A0;h=hk-sfb-1712A0 +``` + +**Read response** (WiFi JSON): + +```json +{"e":true,"k":"ak-pch-1712A0","h":"hk-sfb-1712A0"} +``` + +### Parameter Reference + +| Param | Type | Description | +|---|---|---| +| `e` | bool (`0`/`1` or `true`/`false`) | Enable/disable authentication | +| `k` | string (≤31 chars) | API key value | +| `h` | string (≤31 chars) | HMAC secret key value | +| `g` | bool (`0`/`1` or `true`/`false`) | Generate new device-unique keys | + +### Key Generation (`g=1`) + +When `g=1` is sent, the device calls `ConfigController::generateAuthKeys()` which: + +1. Generates a device-unique deterministic password via `SystemFunctions::GenerateDefaultPassword()` +2. Prefixes it with `ak-` for the API key and `hk-` for the HMAC key +3. Writes both into `config.auth.apiKey` and `config.auth.hmacKey` + +The device ID used for generation is derived from hardware characteristics, making keys unique to each ESP32. Generated keys follow the pattern `ak-XXXX` / `hk-XXXX` where `XXXX` is the device-specific component. + +### Validation Rules + +When setting keys via `k=` or `h=`: + +- **Null key:** Setting a key to an empty or null value **disables auth** (`enabled = false`). This is a safety measure — a blank key with auth enabled would lock out all clients. +- **Too long:** Keys exceeding 31 characters return `ConfigResult::TooLong` and auth is disabled. +- **Enable without keys:** Calling `e=1` when either key is empty returns `ConfigResult::InvalidParameter` — auth cannot be enabled until both keys are set. + +--- + +## /api/index Auth Exposure + +The `/api/index` response always includes the current auth configuration in its `config` section: + +```json +{ + "config": { + … + "auth": { + "enabled": true, + "apiKey": "ak-pch-1712A0", + "hmacKey": "hk-sfb-1712A0" + } + } +} +``` + +This is how the MAUI client discovers the device's auth keys on first connection. The flow: + +1. User enters IP/port in the MAUI app (no auth keys known yet) +2. App calls `GET /api/index` — if auth is disabled, the response includes the keys +3. App reads the keys and stores them in local preferences +4. App calls `ConfigureAuth(apiKey, hmacKey)` on both `DashboardConnection` and `ConfigConnection` +5. Future requests carry auth headers + +When auth is already enabled and the MAUI app has stored keys from a previous session, the app loads them from preferences at startup and configures the auth handler before the first request. + +--- + +## Client-Side Implementation (.NET MAUI) + +### DeviceAuthHandler + +`DeviceAuthHandler` is a `DelegatingHandler` that sits in the `HttpClient` pipeline for both `DashboardConnection` and `ConfigConnection`. It intercepts every outbound request in `SendAsync()` and injects auth headers. + +```csharp +// In SendAsync(): +if (apiKey.Length > 0) + request.Headers.TryAddWithoutValidation("X-API-Key", apiKey); + +if (hmacKey.Length > 0) +{ + string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); + string signInput = $"{timestamp}\n{method}\n{path}\n{body}"; + byte[] hash = HMACSHA256(hmacKeyBytes, signInputBytes); + string signature = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + request.Headers.TryAddWithoutValidation("X-Auth-Timestamp", timestamp); + request.Headers.TryAddWithoutValidation("X-Auth-Signature", signature); +} +``` + +Both mechanisms are applied simultaneously when both keys are configured. The server checks API key first (fast path), then falls back to HMAC. + +Key design decisions in the handler: + +- **Thread-safe state:** API key and HMAC key are stored behind a `lock` and can be updated at runtime via `Configure()` without disrupting in-flight requests. +- **Body buffering:** For POST requests, the handler reads and buffers the request body to compute the HMAC signature, then rewinds it with a `ByteArrayContent` so the inner handler can still read it. This is safe for the small payloads (typically <256 bytes) used by the MAUI app. +- **Streaming fallback:** If body buffering fails, the signature is computed with an empty body — the server will reject the HMAC, but API key auth may still succeed. + +### Auth Sync Flow + +When the MAUI app changes auth settings (enable/disable, set keys, generate keys), the `ConfigConnection` automatically re-syncs the handler: + +``` +SetAuthEnabledAsync() ──┐ +SetAuthApiKeyAsync() ──┼──► POST /api/config/C19 +SetAuthHmacKeyAsync() ──┤ │ +GenerateAuthKeysAsync()──┘ ▼ + SyncAuthHandlerFromDeviceAsync() + │ + GET /api/config/C19 + │ + _authHandler.Configure(apiKey, hmacKey) + │ + _messageBus.Publish(AuthConfigChanged) + │ + PowerHubService.OnAuthConfigChanged() + │ + dc.ConfigureAuth() + cc.ConfigureAuth() +``` + +This ensures that after any auth change, both the config connection (which made the change) and the dashboard connection (which polls `/api/index`) have the updated credentials. Without this sync, the config change would succeed but subsequent requests would fail with `401` because the handler still had the old keys. + +### AuthConfigChanged Message + +A fire-and-forget message published whenever the device's auth configuration changes: + +```csharp +public record AuthConfigChanged(AuthConfigModel Config); +``` + +`PowerHubService` subscribes to this message and propagates the new credentials to both `DashboardConnection` and `ConfigConnection`. This decouples the config-layer auth change from the dashboard-layer credential update — the `ConfigPoller` doesn't need to know about auth internals. + +--- + +## MAUI UI — NetworkSecurityPage + +The `NetworkSecurityPage` (accessible from Settings) provides a UI for managing auth: + +- **Enable/Disable toggle** — switch controlling `config.auth.enabled` +- **API Key entry** — text field for the API key +- **HMAC Key entry** — text field for the HMAC key +- **Generate New Keys** button — sends `g=1` to the device, then refreshes the displayed keys +- **Save All Settings** button — writes all three fields (enabled, API key, HMAC key) and saves to EEPROM + +The page is backed by `NetworkSecurityViewModel` which calls `PowerHubService` methods: + +| UI Action | Service Method | HTTP Call | +|---|---|---| +| Toggle enable | `SetAuthEnabledAsync(bool)` | `POST /api/config/C19?e=0\|1` | +| Save API key | `SetAuthApiKeyAsync(string)` | `POST /api/config/C19?k=…` | +| Save HMAC key | `SetAuthHmacKeyAsync(string)` | `POST /api/config/C19?h=…` | +| Generate keys | `GenerateAuthKeysAsync()` | `POST /api/config/C19?g=1` | +| Refresh | `GetAuthConfigAsync()` | `GET /api/config/C19` | + +--- + +## Connection Lifecycle + +### First Connection (auth disabled) + +``` +MAUI App ESP32 WiFi Server + │ │ + │── GET /api/index ───────────►│ auth disabled, no check + │◄─ {config:{auth:{…}}} ──────│ leaks apiKey, hmacKey + │ │ + │ App stores keys in prefs │ + │ ConfigureAuth(apiKey,hmacKey) + │ │ + │── GET /api/index ───────────►│ auth disabled, but headers sent anyway + │ X-API-Key: ak-… │ +``` + +### Auth Enabled After Configuration + +``` +MAUI App ESP32 WiFi Server + │ │ + │── POST /api/config/C19?e=1 ─►│ enable auth + │◄─ {"success":true} ─────────│ + │ │ + │ SyncAuthHandlerFromDevice() │ + │ (re-reads keys, updates handler) + │ │ + │── GET /api/index ───────────►│ auth check: X-API-Key matches ✓ + │ X-API-Key: ak-… │ + │◄─ 200 OK ───────────────────│ +``` + +### Unauthorised Browser (auth enabled) + +``` +Browser ESP32 WiFi Server + │ │ + │── GET /api/index ───────────►│ auth check: no headers → not authorized + │◄─ 401 {"message":"Unauthorized"} + │ │ +``` + +--- + +## Security Considerations + +| Concern | Mitigation | +|---|---| +| Plain-text HTTP | API key is sent in cleartext. Use HMAC mode for sensitive deployments. The connection is local WiFi only — not exposed to the internet unless the user explicitly port-forwards. | +| Replay attacks | HMAC timestamp window (±300 s) limits replay. Timestamp monotonicity is not enforced, so a captured signature can be replayed within the window. | +| Key exposure in `/api/index` | Keys are always present in the index JSON. When auth is enabled, reading `/api/index` requires a valid key — solving the chicken-and-egg problem for initial setup. Users should disable auth, read keys once, then enable auth. | +| Web UI breakage | The built-in HTML dashboard (`/index`) stops updating when auth is enabled because its JavaScript cannot send auth headers. This is by design. | +| EEPROM wear | Auth keys are stored in EEPROM as part of the `Config` struct. Frequent key regeneration should be avoided — keys are intended to be set once and rarely changed. | + +--- + +## Migration from Pre-Auth Firmware + +1. **Flash updated firmware** — auth defaults to disabled (`enabled == false`). +2. **Open NetworkSecurityPage** in the MAUI app and tap **Generate New Keys**. +3. Note the generated keys (they appear in the text fields). +4. Toggle **Enable Authentication** on. +5. Tap **Save All Settings**. +6. The MAUI app automatically syncs the new keys into its auth handler pipeline. +7. Verify by opening `http:///api/index` in a browser — you should receive `401 Unauthorized`. diff --git a/Docs/SD_CARD_CALLBACK_IMPLEMENTATION.md b/Docs/SDCardCallbackImplementation.md similarity index 100% rename from Docs/SD_CARD_CALLBACK_IMPLEMENTATION.md rename to Docs/SDCardCallbackImplementation.md diff --git a/Docs/SD_CONFIG_README.md b/Docs/SDCardConfigReadme.md similarity index 100% rename from Docs/SD_CONFIG_README.md rename to Docs/SDCardConfigReadme.md diff --git a/Docs/SD_CONFIG_IMPLEMENTATION.md b/Docs/SDCardImplementation.md similarity index 100% rename from Docs/SD_CONFIG_IMPLEMENTATION.md rename to Docs/SDCardImplementation.md diff --git a/Docs/SYNC_WARNING_IMPLEMENTATION.md b/Docs/SyncWarningImplementation.md similarity index 100% rename from Docs/SYNC_WARNING_IMPLEMENTATION.md rename to Docs/SyncWarningImplementation.md diff --git a/PowerControlHub/ConfigCommandHandler.cpp b/PowerControlHub/ConfigCommandHandler.cpp index 22c3c8e..f7bff65 100644 --- a/PowerControlHub/ConfigCommandHandler.cpp +++ b/PowerControlHub/ConfigCommandHandler.cpp @@ -193,6 +193,13 @@ bool ConfigCommandHandler::handleCommand(SerialCommandManager* sender, const cha config->rtc.resetPin); sender->sendCommand(ConfigRtcPins, buffer); + // C19 Network auth + snprintf(buffer, sizeof(buffer), "e=%d;k=%s;h=%s", + config->auth.enabled ? 1 : 0, + config->auth.apiKey, + config->auth.hmacKey); + sender->sendCommand(ConfigAuthCommand, buffer); + // N1–N6 Nextion display config (broadcast as individual commands) snprintf(buffer, sizeof(buffer), "v=%u", config->nextion.enabled ? 1u : 0u); sender->sendCommand(NextionEnabled, buffer); @@ -577,6 +584,75 @@ bool ConfigCommandHandler::handleCommand(SerialCommandManager* sender, const cha result = ConfigResult::InvalidParameter; } } + else if (SystemFunctions::commandMatches(command, ConfigAuthCommand)) + { + // C19 - Network authentication configuration (repurposed from retired Nextion usage) + // Params: e=0|1 (enable), k=, h=, g=1 (auto-generate keys) + // No params returns current state + if (paramCount == 0) + { + // Read-only: return current auth state + Config* config = _configController->getConfigPtr(); + if (config) + { + char buffer[128]; + snprintf(buffer, sizeof(buffer), "e=%d;k=%s;h=%s", + config->auth.enabled ? 1 : 0, + config->auth.apiKey, + config->auth.hmacKey); + sender->sendCommand(ConfigAuthCommand, buffer); + result = ConfigResult::Success; + } + else + { + sendAckErr(sender, command, F("Config not available")); + return true; + } + } + else + { + bool generate = false; + getParamValueBool(params, paramCount, ConfigAuthGenerateParam, generate); + + if (generate) + { + result = _configController->generateAuthKeys(); + } + else + { + bool enabled; + if (getParamValueBool(params, paramCount, ConfigAuthEnabledParam, enabled)) + { + _configController->setAuthEnabled(enabled); + result = ConfigResult::Success; + } + + const char* apiKeyVal = getParamValue(params, paramCount, ConfigAuthApiKeyParam); + if (apiKeyVal) + { + result = _configController->setAuthApiKey(apiKeyVal); + + if (result != ConfigResult::Success) + { + sendAckErr(sender, command, "API key too long"); + return true; + } + } + + const char* hmacKeyVal = getParamValue(params, paramCount, ConfigAuthHmacKeyParam); + if (hmacKeyVal) + { + result = _configController->setAuthHmacKey(hmacKeyVal); + + if (result != ConfigResult::Success) + { + sendAckErr(sender, command, "HMAC key too long"); + return true; + } + } + } + } + } else if (SystemFunctions::commandMatches(command, ConfigTimeZoneOffset)) { // C20 - Set timezone offset (hours from UTC, -12 to +14) @@ -1052,6 +1128,7 @@ const char* const* ConfigCommandHandler::supportedCommands(size_t& count) const ConfigBluetoothEnable, ConfigWifiEnable, ConfigWifiMode, ConfigWifiSSID, ConfigWifiPassword, ConfigWifiPort, ConfigWifiState, ConfigWifiApIpAddress, ConfigRtcPins, + ConfigAuthCommand, #if defined(MQTT_SUPPORT) MqttConfigEnable, MqttConfigBroker, MqttConfigPort, MqttConfigUsername, MqttConfigPassword, MqttConfigDeviceId, MqttConfigHADiscovery, MqttConfigKeepAlive, diff --git a/PowerControlHub/ConfigController.cpp b/PowerControlHub/ConfigController.cpp index 04392bd..8f28719 100644 --- a/PowerControlHub/ConfigController.cpp +++ b/PowerControlHub/ConfigController.cpp @@ -599,12 +599,104 @@ ConfigResult ConfigController::setNextionBaudRate(const uint32_t baudRate) ConfigResult ConfigController::setNextionUartNum(const uint8_t uartNum) { - if (_config == nullptr) - return ConfigResult::InvalidConfig; + if (_config == nullptr) + return ConfigResult::InvalidConfig; + + if (uartNum != 1 && uartNum != 2) + return ConfigResult::InvalidParameter; + + _config->nextion.uartNum = uartNum; + return ConfigResult::Success; +} + +// Generate device-unique API and HMAC keys and store them in config.auth +ConfigResult ConfigController::generateAuthKeys() +{ + if (_config == nullptr) + return ConfigResult::InvalidConfig; + + char deviceId[32]; + deviceId[0] = '\0'; + + if (SystemFunctions::GenerateDefaultPassword(deviceId, sizeof(deviceId)) != BufferSuccess) + return ConfigResult::Failed; + + // apiKey = "ak-" + deviceId, hmacKey = "hk-" + deviceId + char apiKey[ConfigAuthApiKeyLength]; + char hmacKey[ConfigAuthHmacKeyLength]; + + snprintf(apiKey, sizeof(apiKey), "ak-%s", deviceId); + apiKey[sizeof(apiKey) - 1] = '\0'; + + snprintf(hmacKey, sizeof(hmacKey), "hk-%s", deviceId); + hmacKey[sizeof(hmacKey) - 1] = '\0'; - if (uartNum != 1 && uartNum != 2) + strncpy(_config->auth.apiKey, apiKey, sizeof(_config->auth.apiKey) - 1); + _config->auth.apiKey[sizeof(_config->auth.apiKey) - 1] = '\0'; + + strncpy(_config->auth.hmacKey, hmacKey, sizeof(_config->auth.hmacKey) - 1); + _config->auth.hmacKey[sizeof(_config->auth.hmacKey) - 1] = '\0'; + + return ConfigResult::Success; +} + +ConfigResult ConfigController::setAuthEnabled(const bool enabled) +{ + if (_config == nullptr) + return ConfigResult::InvalidConfig; + + if (enabled && (strlen(_config->auth.apiKey) == 0 || strlen(_config->auth.hmacKey) == 0)) + { return ConfigResult::InvalidParameter; + } - _config->nextion.uartNum = uartNum; - return ConfigResult::Success; + _config->auth.enabled = enabled; + + return ConfigResult::Success; +} + +ConfigResult ConfigController::setAuthApiKey(const char* apiKey) +{ + if (_config == nullptr) + return ConfigResult::InvalidConfig; + + if (apiKey == nullptr) + { + setAuthEnabled(false); + return ConfigResult::InvalidParameter; + } + + if (strlen(apiKey) > ConfigAuthApiKeyLength - 1) + { + setAuthEnabled(false); + return ConfigResult::TooLong; + } + + strncpy(_config->auth.apiKey, apiKey, sizeof(_config->auth.apiKey) - 1); + _config->auth.apiKey[sizeof(_config->auth.apiKey) - 1] = '\0'; + + return ConfigResult::Success; +} + +ConfigResult ConfigController::setAuthHmacKey(const char* hmacKey) +{ + if (_config == nullptr) + return ConfigResult::InvalidConfig; + + if (hmacKey == nullptr) + { + setAuthEnabled(false); + return ConfigResult::InvalidParameter; + } + + if (strlen(hmacKey) > ConfigAuthHmacKeyLength - 1) + { + setAuthEnabled(false); + return ConfigResult::TooLong; + } + + strncpy(_config->auth.hmacKey, hmacKey, sizeof(_config->auth.hmacKey) - 1); + _config->auth.hmacKey[sizeof(_config->auth.hmacKey) - 1] = '\0'; + + return ConfigResult::Success; } \ No newline at end of file diff --git a/PowerControlHub/ConfigController.h b/PowerControlHub/ConfigController.h index 81f5b9f..7cba76a 100644 --- a/PowerControlHub/ConfigController.h +++ b/PowerControlHub/ConfigController.h @@ -91,4 +91,10 @@ class ConfigController ConfigResult setNextionTxPin(const uint8_t txPin); ConfigResult setNextionBaudRate(const uint32_t baudRate); ConfigResult setNextionUartNum(const uint8_t uartNum); + + // Network authentication helpers + ConfigResult generateAuthKeys(); + ConfigResult setAuthEnabled(const bool enabled); + ConfigResult setAuthApiKey(const char* apiKey); + ConfigResult setAuthHmacKey(const char* hmacKey); }; \ No newline at end of file diff --git a/PowerControlHub/ConfigNetworkHandler.cpp b/PowerControlHub/ConfigNetworkHandler.cpp index 7b834a8..0a5b097 100644 --- a/PowerControlHub/ConfigNetworkHandler.cpp +++ b/PowerControlHub/ConfigNetworkHandler.cpp @@ -23,6 +23,7 @@ #include "SystemFunctions.h" #include "RelayController.h" + ConfigNetworkHandler::ConfigNetworkHandler(ConfigController* configController, WifiController* wifiController, RelayController* relayController) : _configController(configController), _wifiController(wifiController), _relayController(relayController) { @@ -380,6 +381,88 @@ CommandResult ConfigNetworkHandler::handleRequest(const char* method, result = ConfigResult::InvalidParameter; } } + else if (SystemFunctions::commandMatches(command, ConfigAuthCommand)) + { + // C19 - Network authentication configuration + if (paramCount == 0) + { + Config* config = _configController->getConfigPtr(); + if (config) + { + snprintf(responseBuffer, bufferSize, "\"e\":%s,\"k\":\"%s\",\"h\":\"%s\"", + config->auth.enabled ? "true" : "false", + config->auth.apiKey, + config->auth.hmacKey); + } + else + { + formatJsonResponse(responseBuffer, bufferSize, false, "Config not available"); + } + } + else + { + bool generate = false; + getParamValueBool(params, paramCount, ConfigAuthGenerateParam, generate); + + if (generate) + { + result = _configController->generateAuthKeys(); + + if (result == ConfigResult::Success) + { + formatJsonResponse(responseBuffer, bufferSize, true, ""); + } + } + else + { + const char* apiKeyVal = getParamValue(params, paramCount, ConfigAuthApiKeyParam); + + if (apiKeyVal) + { + result = _configController->setAuthApiKey(apiKeyVal); + if (result != ConfigResult::Success) + { + const char* msg = (result == ConfigResult::TooLong) + ? "API key too long" + : "API key invalid"; + formatJsonResponse(responseBuffer, bufferSize, false, msg); + return CommandResult(false); + } + } + + const char* hmacKeyVal = getParamValue(params, paramCount, ConfigAuthHmacKeyParam); + + if (hmacKeyVal) + { + result = _configController->setAuthHmacKey(hmacKeyVal); + if (result != ConfigResult::Success) + { + const char* msg = (result == ConfigResult::TooLong) + ? "HMAC key too long" + : "HMAC key invalid"; + formatJsonResponse(responseBuffer, bufferSize, false, msg); + return CommandResult(false); + } + } + + bool enabled; + if (getParamValueBool(params, paramCount, ConfigAuthEnabledParam, enabled)) + { + result = _configController->setAuthEnabled(enabled); + if (result != ConfigResult::Success) + { + formatJsonResponse(responseBuffer, bufferSize, false, "Cannot enable auth: keys not set"); + return CommandResult(false); + } + } + + if (result == ConfigResult::Success) + { + formatJsonResponse(responseBuffer, bufferSize, true, ""); + } + } + } + } else if (SystemFunctions::commandMatches(command, NextionGetConfig)) { // N0 - returns all nextion settings as a JSON-style response in responseBuffer @@ -1128,6 +1211,17 @@ void ConfigNetworkHandler::formatStatusJson(IWifiClient* client) // C32 SD Card CS pin client->print("\"sdCardCsPin\":"); client->print(config->sdCard.csPin); + client->print(","); + + // C19 Network auth + client->print("\"auth\":{"); + client->print("\"enabled\":"); + client->print(config->auth.enabled ? "true" : "false"); + client->print(",\"apiKey\":\""); + client->print(config->auth.apiKey); + client->print("\",\"hmacKey\":\""); + client->print(config->auth.hmacKey); + client->print("\"}"); client->print("}"); } diff --git a/PowerControlHub/SystemDefinitions.h b/PowerControlHub/SystemDefinitions.h index f669250..81652b7 100644 --- a/PowerControlHub/SystemDefinitions.h +++ b/PowerControlHub/SystemDefinitions.h @@ -96,6 +96,7 @@ constexpr char ConfigWifiApIpAddress[] = "C17"; constexpr char ConfigRtcPins[] = "C18"; constexpr char ConfigNextion[] = "C19"; +constexpr char ConfigAuthCommand[] = "C19"; constexpr char ConfigTimeZoneOffset[] = "C20"; constexpr char ConfigMmsi[] = "C21"; constexpr char ConfigCallSign[] = "C22"; @@ -214,6 +215,13 @@ constexpr char DefaultApIpAddress[MaxIpAddressLength] = "192.168.4.1"; constexpr uint16_t DefaultWifiPort = 80; +// Parameter keys for C19 (auth) +constexpr char ConfigAuthEnabledParam[] = "e"; // enabled=0/1 +constexpr char ConfigAuthApiKeyParam[] = "k"; // api key value +constexpr char ConfigAuthHmacKeyParam[] = "h"; // hmac key value +constexpr char ConfigAuthGenerateParam[] = "g"; // generate keys when set to 1 + + // WiFi Connection Quality Thresholds constexpr int8_t WeakSignalWarningRSSI = -80; // dBm - warn user diff --git a/PowerControlHub/SystemFunctions.cpp b/PowerControlHub/SystemFunctions.cpp index 8c37e1e..f4b952b 100644 --- a/PowerControlHub/SystemFunctions.cpp +++ b/PowerControlHub/SystemFunctions.cpp @@ -20,6 +20,10 @@ #include "SystemDefinitions.h" #include "ConfigManager.h" +#if defined(WIFI_SUPPORT) && defined(ESP32) +#include "mbedtls/md.h" +#endif + #if !defined(WIFI_SUPPORT) #include #endif @@ -53,6 +57,51 @@ uint16_t SystemFunctions::stackAvailable() return (unsigned int)&v - (__brkval == 0 ? (unsigned int)&__heap_start : (unsigned int)__brkval); } +bool SystemFunctions::ComputeHmacSha256(const char* key, const char* message, char* outHex, size_t outHexSize) +{ +#if defined(WIFI_SUPPORT) && defined(ESP32) + if (!key || !message || !outHex || outHexSize < 65) + return false; + + const mbedtls_md_info_t* md_info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256); + + if (!md_info) + return false; + + unsigned char hmac[32]; + mbedtls_md_context_t ctx; + mbedtls_md_init(&ctx); + + if (mbedtls_md_setup(&ctx, md_info, 1) != 0) + { + mbedtls_md_free(&ctx); + return false; + } + + if (mbedtls_md_hmac_starts(&ctx, reinterpret_cast(key), strlen(key)) != 0 || + mbedtls_md_hmac_update(&ctx, reinterpret_cast(message), strlen(message)) != 0 || + mbedtls_md_hmac_finish(&ctx, hmac) != 0) + { + mbedtls_md_free(&ctx); + return false; + } + + mbedtls_md_free(&ctx); + + // Convert to lowercase hex + for (int i = 0; i < 32; ++i) + { + snprintf(outHex + i * 2, outHexSize - i * 2, "%02x", hmac[i]); + } + + outHex[64] = '\0'; + return true; +#else + (void)key; (void)message; (void)outHex; (void)outHexSize; + return false; +#endif +} + uint8_t SystemFunctions::getUsedPins(uint8_t* pins, uint8_t maxCount) { if (!pins || maxCount == 0) diff --git a/PowerControlHub/SystemFunctions.h b/PowerControlHub/SystemFunctions.h index 1db4899..e6b90f6 100644 --- a/PowerControlHub/SystemFunctions.h +++ b/PowerControlHub/SystemFunctions.h @@ -63,6 +63,17 @@ class SystemFunctions */ static uint8_t GenerateDefaultPassword(char* buffer, size_t bufferSize); + /** + * @brief Compute HMAC-SHA256 of a message using a key and return lowercase hex string. + * + * @param key HMAC key + * @param message Message to sign + * @param outHex Buffer to receive lowercase hex string (must be at least 65 bytes for 32 bytes + null) + * @param outHexSize Size of outHex buffer + * @return true on success, false on failure or if not supported on platform + */ + static bool ComputeHmacSha256(const char* key, const char* message, char* outHex, size_t outHexSize); + /** * @brief Derive a hardware-unique 32-bit serial number from factory-programmed chip identity. * diff --git a/PowerControlHub/WifiController.h b/PowerControlHub/WifiController.h index 5060d0d..80b7d6e 100644 --- a/PowerControlHub/WifiController.h +++ b/PowerControlHub/WifiController.h @@ -189,6 +189,9 @@ class WifiController : public IWifiController _wifiServer->setClientMode(cfg->network.ssid, cfg->network.password); } + // Provide auth configuration to server (pointer owned by global config) + _wifiServer->setAuthConfig(&cfg->auth); + if (!_wifiServer->begin()) { if (_warningManager) diff --git a/PowerControlHub/WifiServer.cpp b/PowerControlHub/WifiServer.cpp index 3134421..9420c28 100644 --- a/PowerControlHub/WifiServer.cpp +++ b/PowerControlHub/WifiServer.cpp @@ -19,6 +19,7 @@ #include "WifiServer.h" #include "SystemFunctions.h" #include "ChunkedWifiClient.h" +#include "DateTimeManager.h" constexpr char response400[] = "\"error\":\"Bad Request\",\"message\":\"The request will not process due to client error\""; @@ -27,24 +28,25 @@ constexpr uint8_t RestartDelay = 100; WifiServer::WifiServer(MessageBus* messageBus, SerialCommandManager* commandMgrComputer, WarningManager* warningManager, uint16_t port, INetworkCommandHandler** handlers, uint8_t handlerCount, NetworkJsonVisitor** jsonVisitors, uint8_t jsonVisitorCount, IWifiRadio* radio) - : SingleLoggerSupport(commandMgrComputer), - _messageBus(messageBus), - _serverActive(false), - _mode(WifiMode::AccessPoint), - _connectionState(WifiConnectionState::Disconnected), - _port(port), - _initialized(false), - _warningManager(warningManager), - _handlers(handlers), - _handlerCount(handlerCount), - _jsonVisitors(nullptr), - _jsonVisitorCount(0), - _lastConnectionAttempt(0), - _connectionStartTime(0), - _consecutiveFailures(0), - _lastRSSI(0), - _lastRSSICheck(0), - _radio(radio) + : SingleLoggerSupport(commandMgrComputer), + _messageBus(messageBus), + _serverActive(false), + _mode(WifiMode::AccessPoint), + _connectionState(WifiConnectionState::Disconnected), + _port(port), + _initialized(false), + _warningManager(warningManager), + _handlers(handlers), + _handlerCount(handlerCount), + _jsonVisitors(nullptr), + _jsonVisitorCount(0), + _lastConnectionAttempt(0), + _connectionStartTime(0), + _consecutiveFailures(0), + _lastRSSI(0), + _lastRSSICheck(0), + _radio(radio), + _authConfig(nullptr) { _ssid[0] = '\0'; _password[0] = '\0'; @@ -88,7 +90,7 @@ void WifiServer::setAccessPointMode(const char* ssid, const char* password, cons { _ipAddress[0] = '\0'; } - + if (password != nullptr) { strncpy(_password, password, sizeof(_password) - 1); @@ -115,9 +117,9 @@ bool WifiServer::begin() { return true; } - + bool success = false; - + if (_mode == WifiMode::AccessPoint) { IPAddress apIp; @@ -134,7 +136,7 @@ bool WifiServer::begin() IPAddress subnet(255, 255, 255, 0); success = _radio->beginAP(_ssid, _password, apIp, subnet); - + if (success) { sendDebug(F("Access Point started."), F("WifiServer")); @@ -155,12 +157,12 @@ bool WifiServer::begin() _connectionState = WifiConnectionState::Connecting; _connectionStartTime = SystemFunctions::millis64(); _lastConnectionAttempt = _connectionStartTime; - + // Return true to indicate initialization started (not necessarily connected yet) success = true; _initialized = true; } - + return success; } @@ -247,7 +249,7 @@ uint8_t WifiServer::getPersistentClientCount(uint8_t excludeIndex) continue; } - if (_activeClients[i].isPersistent && + if (_activeClients[i].isPersistent && _activeClients[i].state != ClientHandlingState::Idle) { count++; @@ -292,16 +294,16 @@ bool WifiServer::isStaticAssetRequest(const char* path) // (This is a JSON-only API server, so reject these early with specific logging) const char* ext = lastDot + 1; return (strcmp(ext, "css") == 0 || - strcmp(ext, "js") == 0 || - strcmp(ext, "ico") == 0 || // favicon.ico - strcmp(ext, "png") == 0 || - strcmp(ext, "jpg") == 0 || - strcmp(ext, "jpeg") == 0 || - strcmp(ext, "gif") == 0 || - strcmp(ext, "svg") == 0 || - strcmp(ext, "woff") == 0 || // web fonts - strcmp(ext, "woff2") == 0 || - strcmp(ext, "ttf") == 0); + strcmp(ext, "js") == 0 || + strcmp(ext, "ico") == 0 || // favicon.ico + strcmp(ext, "png") == 0 || + strcmp(ext, "jpg") == 0 || + strcmp(ext, "jpeg") == 0 || + strcmp(ext, "gif") == 0 || + strcmp(ext, "svg") == 0 || + strcmp(ext, "woff") == 0 || // web fonts + strcmp(ext, "woff2") == 0 || + strcmp(ext, "ttf") == 0); } bool WifiServer::acceptNewClient(IWifiClient* client, uint64_t now) @@ -331,7 +333,7 @@ void WifiServer::updateClientHandling() uint64_t now = SystemFunctions::millis64(); // Process all active clients -for (uint8_t i = 0; i < MaxConcurrentClients; i++) + for (uint8_t i = 0; i < MaxConcurrentClients; i++) { if (_activeClients[i].state != ClientHandlingState::Idle) { @@ -365,134 +367,134 @@ void WifiServer::handleClientState(uint8_t index, uint64_t now) switch (client.state) { - case ClientHandlingState::ReadingRequest: + case ClientHandlingState::ReadingRequest: + { + // Check for timeout + if (SystemFunctions::hasElapsed(now, client.startTime, ClientReadTimeoutMs)) { - // Check for timeout - if (SystemFunctions::hasElapsed(now, client.startTime, ClientReadTimeoutMs)) + sendDebug(F("Client read timeout"), F("WifiServer")); + cleanupClient(index); + break; + } + + // Read available data (non-blocking) + // Don't check connected() here - it can be false during TCP handshake + // The timeout will catch truly dead connections + bool requestComplete = false; + size_t requestLen = strlen(client.request); + + while (client.client->available()) + { + char c = client.client->read(); + + // Append character to buffer + if (requestLen < MaximumRequestSize) + { + client.request[requestLen++] = c; + client.request[requestLen] = '\0'; + client.lastActivity = now; + } + + // Safety check for request size + if (requestLen >= MaximumRequestSize) { - sendDebug(F("Client read timeout"), F("WifiServer")); + sendDebug(F("Request too large"), F("WifiServer")); + send400(*client.client, false); cleanupClient(index); - break; + return; } - // Read available data (non-blocking) - // Don't check connected() here - it can be false during TCP handshake - // The timeout will catch truly dead connections - bool requestComplete = false; - size_t requestLen = strlen(client.request); + // Determine if the full request (headers + any body) has been received. + // For GET: complete once we see the blank line (\r\n\r\n). + // For POST: complete once Content-Length body bytes have also been read. + // Content-Length is only honoured for methods that carry a body (POST); + // applying it to GET would stall completion if the client sends a + // Content-Length header (some clients do). + const char* headerEnd = strstr(client.request, "\r\n\r\n"); + if (headerEnd) + { + size_t headerEndIdx = (headerEnd - client.request) + 4; + size_t bodyRequired = 0; - while (client.client->available()) + if (strncmp(client.request, "POST", 4) == 0) { - char c = client.client->read(); - - // Append character to buffer - if (requestLen < MaximumRequestSize) + const char* clHeader = strstr(client.request, "Content-Length:"); + if (clHeader) { - client.request[requestLen++] = c; - client.request[requestLen] = '\0'; - client.lastActivity = now; - } - - // Safety check for request size - if (requestLen >= MaximumRequestSize) - { - sendDebug(F("Request too large"), F("WifiServer")); - send400(*client.client, false); - cleanupClient(index); - return; - } - - // Determine if the full request (headers + any body) has been received. - // For GET: complete once we see the blank line (\r\n\r\n). - // For POST: complete once Content-Length body bytes have also been read. - // Content-Length is only honoured for methods that carry a body (POST); - // applying it to GET would stall completion if the client sends a - // Content-Length header (some clients do). - const char* headerEnd = strstr(client.request, "\r\n\r\n"); - if (headerEnd) - { - size_t headerEndIdx = (headerEnd - client.request) + 4; - size_t bodyRequired = 0; + clHeader += 15; + while (*clHeader == ' ') clHeader++; + int32_t cl = atoi(clHeader); - if (strncmp(client.request, "POST", 4) == 0) + if (cl < 0 || static_cast(cl) > MaximumRequestSize - headerEndIdx) { - const char* clHeader = strstr(client.request, "Content-Length:"); - if (clHeader) - { - clHeader += 15; - while (*clHeader == ' ') clHeader++; - int32_t cl = atoi(clHeader); - - if (cl < 0 || static_cast(cl) > MaximumRequestSize - headerEndIdx) - { - sendDebug(F("Invalid Content-Length"), F("WifiServer")); - send400(*client.client, false); - cleanupClient(index); - return; - } - - bodyRequired = static_cast(cl); - } + sendDebug(F("Invalid Content-Length"), F("WifiServer")); + send400(*client.client, false); + cleanupClient(index); + return; } - if (requestLen >= headerEndIdx + bodyRequired) - { - requestComplete = true; - char msg[48]; - snprintf(msg, sizeof(msg), "Request complete [slot %d, %d bytes]", index, requestLen); - sendDebug(msg, F("WifiServer")); - break; // Exit read loop — any further bytes belong to the next request - } + bodyRequired = static_cast(cl); } } - // Transition to processing if request is complete - if (requestComplete) + if (requestLen >= headerEndIdx + bodyRequired) { - client.state = ClientHandlingState::ProcessingRequest; + requestComplete = true; + char msg[48]; + snprintf(msg, sizeof(msg), "Request complete [slot %d, %d bytes]", index, requestLen); + sendDebug(msg, F("WifiServer")); + break; // Exit read loop — any further bytes belong to the next request } - - break; + } } - case ClientHandlingState::ProcessingRequest: + // Transition to processing if request is complete + if (requestComplete) { - processClientRequest(index); - break; + client.state = ClientHandlingState::ProcessingRequest; } - case ClientHandlingState::KeepAlive: - { - // Check for timeout - if (SystemFunctions::hasElapsed(now, client.lastActivity, PersistentTimeoutMs)) - { - sendDebug(F("Persistent connection timeout"), F("WifiServer")); - cleanupClient(index); - break; - } + break; + } - // Check if still connected - if (!client.client->connected()) - { - sendDebug(F("Persistent client disconnected"), F("WifiServer")); - cleanupClient(index); - break; - } + case ClientHandlingState::ProcessingRequest: + { + processClientRequest(index); + break; + } - // Check for new data on the persistent connection - if (client.client->available()) - { - sendDebug(F("New request on persistent connection"), F("WifiServer")); - client.request[0] = '\0'; - client.startTime = now; - client.state = ClientHandlingState::ReadingRequest; - } + case ClientHandlingState::KeepAlive: + { + // Check for timeout + if (SystemFunctions::hasElapsed(now, client.lastActivity, PersistentTimeoutMs)) + { + sendDebug(F("Persistent connection timeout"), F("WifiServer")); + cleanupClient(index); break; } - case ClientHandlingState::Idle: - // Nothing to do + // Check if still connected + if (!client.client->connected()) + { + sendDebug(F("Persistent client disconnected"), F("WifiServer")); + cleanupClient(index); break; + } + + // Check for new data on the persistent connection + if (client.client->available()) + { + sendDebug(F("New request on persistent connection"), F("WifiServer")); + client.request[0] = '\0'; + client.startTime = now; + client.state = ClientHandlingState::ReadingRequest; + } + break; + } + + case ClientHandlingState::Idle: + // Nothing to do + break; } } @@ -557,6 +559,7 @@ void WifiServer::processClientRequest(uint8_t index) // Parse the request line (GET /path?query HTTP/1.1) // Find first space (after method) char* firstSpace = strchr(client.request, ' '); + if (!firstSpace) { send404(*client.client, isPersistent); @@ -569,12 +572,14 @@ void WifiServer::processClientRequest(uint8_t index) client.state = ClientHandlingState::KeepAlive; client.lastActivity = SystemFunctions::millis64(); } + return; } // Extract method size_t methodLen = firstSpace - client.request; char method[8]; // Enough for "DELETE" + null + if (methodLen >= sizeof(method)) { send404(*client.client, isPersistent); @@ -589,6 +594,7 @@ void WifiServer::processClientRequest(uint8_t index) } return; } + strncpy(method, client.request, methodLen); method[methodLen] = '\0'; @@ -748,13 +754,131 @@ void WifiServer::processClientRequest(uint8_t index) // the parser cannot stray beyond the intended body. size_t available = strlen(bodyStart); size_t bodyLen = (contentLength > 0 && static_cast(contentLength) < available) - ? static_cast(contentLength) - : available; + ? static_cast(contentLength) + : available; client.request[bodyStart - client.request + bodyLen] = '\0'; body = bodyStart; } } + // Enforce network auth if configured - check BEFORE any route handling + if (_authConfig != nullptr && _authConfig->enabled) + { + // Parse a few common auth headers from the raw request buffer + char headerApiKey[64] = {}; + char headerTimestamp[32] = {}; + char headerSignature[130] = {}; + + auto extractHeader = [&](const char* headerName, char* out, size_t outSize) -> bool { + // search for both case variants + const char* p = strstr(client.request, headerName); + + if (!p) + { + // try lowercase variant + char lower[32]; + size_t hn = strlen(headerName); + + if (hn >= sizeof(lower)) + return false; + + for (size_t i = 0; i < hn; ++i) + lower[i] = tolower((unsigned char)headerName[i]); + + lower[hn] = '\0'; + p = strstr(client.request, lower); + + if (!p) + return false; + } + + // move past header name and ':' + p = strchr(p, ':'); + + if (!p) return + false; + + ++p; + while (*p == ' ' || *p == '\t') + ++p; + + const char* end = strstr(p, "\r\n"); + + if (!end) + end = strchr(p, '\n'); + + size_t len = end ? static_cast(end - p) : strlen(p); + + if (len >= outSize) + len = outSize - 1; + + strncpy(out, p, len); + out[len] = '\0'; + return true; + }; + + extractHeader("X-API-Key:", headerApiKey, sizeof(headerApiKey)); + extractHeader("X-Auth-Timestamp:", headerTimestamp, sizeof(headerTimestamp)); + extractHeader("X-Auth-Signature:", headerSignature, sizeof(headerSignature)); + + bool authorized = false; + + // API key match grants access + if (headerApiKey[0] != '\0') + { + if (strcmp(headerApiKey, reinterpret_cast(_authConfig->apiKey)) == 0) + authorized = true; + } + + // HMAC verification + if (!authorized && headerTimestamp[0] != '\0' && headerSignature[0] != '\0') + { + // Validate timestamp window + uint64_t ts = static_cast(strtoull(headerTimestamp, nullptr, 10)); + if (DateTimeManager::isTimeSet()) + { + uint64_t now = DateTimeManager::getCurrentTime(); + uint64_t diff = (now > ts) ? (now - ts) : (ts - now); + if (diff <= 300) // 5 minute window + { + // Compose signing input: timestamp + "\n" + method + "\n" + path + "\n" + body + char signInput[MaximumRequestSize] = {}; + snprintf(signInput, sizeof(signInput), "%s\n%s\n%s\n%s", + headerTimestamp, method, path, body ? body : ""); + + char computed[65] = {}; + if (SystemFunctions::ComputeHmacSha256(reinterpret_cast(_authConfig->hmacKey), signInput, computed, sizeof(computed))) + { + // Compare lower-case hex + if (strcmp(computed, headerSignature) == 0) + authorized = true; + } + } + } + } + + if (!authorized) + { + // Unauthorized + sendResponse(*client.client, 401, "application/json", "{\"success\":false,\"message\":\"Unauthorized\"}", isPersistent); + if (!isPersistent) + { + cleanupClient(index); + } + else + { + client.request[0] = '\0'; + client.lastActivity = SystemFunctions::millis64(); + client.state = ClientHandlingState::KeepAlive; + } + if (_messageBus) + { + _messageBus->publish(method, path, query, false); + } + return; + } + } + if (SystemFunctions::startsWith(path, F("/api/index"))) { handleIndex(*client.client, client.isPersistent, path); @@ -817,18 +941,18 @@ void WifiServer::sendResponse(IWifiClient& client, int statusCode, const char* c switch (statusCode) { - case 200: - client.println(F("OK")); - break; - case 400: - client.println(F("Bad Request")); - break; - case 404: - client.println(F("Not Found")); - break; - default: - client.println(F("Unknown")); - break; + case 200: + client.println(F("OK")); + break; + case 400: + client.println(F("Bad Request")); + break; + case 404: + client.println(F("Not Found")); + break; + default: + client.println(F("Unknown")); + break; } client.print(F("Content-Type: ")); @@ -864,7 +988,7 @@ bool WifiServer::isConnected() const { return false; } - + if (_mode == WifiMode::AccessPoint) { return true; // AP is always "connected" once initialized @@ -1049,7 +1173,7 @@ bool WifiServer::dispatchToHandler(IWifiClient& client, INetworkCommandHandler* int32_t eqIdx = SystemFunctions::indexOf(line, '=', 0); if (eqIdx > 0) { - SystemFunctions::substr(params[paramCount].key, sizeof(params[paramCount].key), line, 0, eqIdx); + SystemFunctions::substr(params[paramCount].key, sizeof(params[paramCount].key), line, 0, eqIdx); SystemFunctions::substr(params[paramCount].value, sizeof(params[paramCount].value), line, eqIdx + 1); paramCount++; } @@ -1082,7 +1206,7 @@ bool WifiServer::dispatchToHandler(IWifiClient& client, INetworkCommandHandler* int32_t equalsIdx = SystemFunctions::indexOf(param, '=', 0); if (equalsIdx != -1) { - SystemFunctions::substr(params[paramCount].key, sizeof(params[paramCount].key), param, 0, equalsIdx); + SystemFunctions::substr(params[paramCount].key, sizeof(params[paramCount].key), param, 0, equalsIdx); SystemFunctions::substr(params[paramCount].value, sizeof(params[paramCount].value), param, equalsIdx + 1); paramCount++; } @@ -1116,7 +1240,7 @@ bool WifiServer::dispatchToHandler(IWifiClient& client, INetworkCommandHandler* } // Log parameters passed to handler - char paramDbg[128]; + char paramDbg[512]; for (uint8_t p = 0; p < paramCount; p++) { snprintf(paramDbg, sizeof(paramDbg), "Param[%d] %*s=%*s", p, (int)sizeof(params[p].key) - 1, params[p].key, (int)sizeof(params[p].value) - 1, params[p].value); @@ -1136,7 +1260,7 @@ bool WifiServer::dispatchToHandler(IWifiClient& client, INetworkCommandHandler* sendDebug("Handler error response", F("WifiServer")); // Also log parameters to help reproduce the error - char paramDbg[128]; + char paramDbg[512]; for (uint8_t p = 0; p < paramCount; p++) { snprintf(paramDbg, sizeof(paramDbg), "Param[%d] %*s=%*s", p, (int)sizeof(params[p].key) - 1, params[p].key, (int)sizeof(params[p].value) - 1, params[p].value); @@ -1156,8 +1280,8 @@ bool WifiServer::dispatchToHandler(IWifiClient& client, INetworkCommandHandler* for (uint8_t p = 0; p < paramCount; p++) { - char paramDbg[128]; - snprintf(paramDbg, sizeof(paramDbg), "Param[%d] %*s=%*s", p, (int)sizeof(params[p].key) - 1, params[p].key, (int)sizeof(params[p].value) - 1 , params[p].value); + char paramDbg[512]; + snprintf(paramDbg, sizeof(paramDbg), "Param[%d] %*s=%*s", p, (int)sizeof(params[p].key) - 1, params[p].key, (int)sizeof(params[p].value) - 1, params[p].value); sendDebug(paramDbg, F("WifiServer")); } @@ -1174,107 +1298,107 @@ void WifiServer::updateClientConnection() switch (_connectionState) { - case WifiConnectionState::Connecting: - // Check connection status periodically - if (SystemFunctions::hasElapsed(now, _lastConnectionAttempt, ConnectionCheckIntervalMs)) + case WifiConnectionState::Connecting: + // Check connection status periodically + if (SystemFunctions::hasElapsed(now, _lastConnectionAttempt, ConnectionCheckIntervalMs)) + { + _lastConnectionAttempt = now; + + if (status == WifiConnectionState::Connected) { - _lastConnectionAttempt = now; + _connectionState = WifiConnectionState::Connected; + _consecutiveFailures = 0; + _lastRSSI = _radio->rssi(); + IPAddress ip = _radio->localIP(); + snprintf(_ipAddress, sizeof(_ipAddress), "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); - if (status == WifiConnectionState::Connected) - { - _connectionState = WifiConnectionState::Connected; - _consecutiveFailures = 0; - _lastRSSI = _radio->rssi(); - IPAddress ip = _radio->localIP(); - snprintf(_ipAddress, sizeof(_ipAddress), "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); - - sendDebug(F("WiFi connected!"), F("WifiServer")); - - // Start the HTTP server now that we're connected - startServer(); - } - else if (SystemFunctions::hasElapsed(now, _connectionStartTime, ConnectionTimeoutMs)) - { - // Connection timeout - _connectionState = WifiConnectionState::Failed; - _consecutiveFailures++; - sendError(F("WiFi connection timeout"), F("WifiServer")); - } + sendDebug(F("WiFi connected!"), F("WifiServer")); + + // Start the HTTP server now that we're connected + startServer(); } - break; - - case WifiConnectionState::Connected: - { - // Monitor for disconnection - if (status != WifiConnectionState::Connected) + else if (SystemFunctions::hasElapsed(now, _connectionStartTime, ConnectionTimeoutMs)) { - _connectionState = WifiConnectionState::Disconnected; - sendDebug(F("WiFi connection lost"), F("WifiServer")); - stopServer(); // Stop server to clean up TCP state - break; + // Connection timeout + _connectionState = WifiConnectionState::Failed; + _consecutiveFailures++; + sendError(F("WiFi connection timeout"), F("WifiServer")); } - - // Check RSSI periodically to detect weak signal before disconnection - if (SystemFunctions::hasElapsed(now, _lastRSSICheck, RSSICheckIntervalMs)) + } + break; + + case WifiConnectionState::Connected: + { + // Monitor for disconnection + if (status != WifiConnectionState::Connected) + { + _connectionState = WifiConnectionState::Disconnected; + sendDebug(F("WiFi connection lost"), F("WifiServer")); + stopServer(); // Stop server to clean up TCP state + break; + } + + // Check RSSI periodically to detect weak signal before disconnection + if (SystemFunctions::hasElapsed(now, _lastRSSICheck, RSSICheckIntervalMs)) + { + _lastRSSICheck = now; + _lastRSSI = _radio->rssi(); + + if (_lastRSSI < WeakSignalWarningRSSI) { - _lastRSSICheck = now; - _lastRSSI = _radio->rssi(); - - if (_lastRSSI < WeakSignalWarningRSSI) - { - if (_warningManager && !_warningManager->isWarningActive(WarningType::WeakWifiSignal)) - { - _warningManager->raiseWarning(WarningType::WeakWifiSignal); - } - } - else if (_warningManager && _warningManager->isWarningActive(WarningType::WeakWifiSignal)) + if (_warningManager && !_warningManager->isWarningActive(WarningType::WeakWifiSignal)) { - _warningManager->clearWarning(WarningType::WeakWifiSignal); + _warningManager->raiseWarning(WarningType::WeakWifiSignal); } } + else if (_warningManager && _warningManager->isWarningActive(WarningType::WeakWifiSignal)) + { + _warningManager->clearWarning(WarningType::WeakWifiSignal); + } + } - break; + break; + } + + case WifiConnectionState::Disconnected: + case WifiConnectionState::Failed: + { + // Use exponential backoff after multiple failures + uint64_t retryInterval = ConnectionRetryIntervalMs; + if (_consecutiveFailures >= MaxConsecutiveFailures) + { + retryInterval = BackoffIntervalMs; } - - case WifiConnectionState::Disconnected: - case WifiConnectionState::Failed: + + // Attempt reconnection after retry interval + if (SystemFunctions::hasElapsed(now, _lastConnectionAttempt, retryInterval)) { - // Use exponential backoff after multiple failures - uint64_t retryInterval = ConnectionRetryIntervalMs; if (_consecutiveFailures >= MaxConsecutiveFailures) { - retryInterval = BackoffIntervalMs; + sendDebug(F("WiFi reconnection attempt"), F("WifiServer")); } - - // Attempt reconnection after retry interval - if (SystemFunctions::hasElapsed(now, _lastConnectionAttempt, retryInterval)) + else { - if (_consecutiveFailures >= MaxConsecutiveFailures) - { - sendDebug(F("WiFi reconnection attempt"), F("WifiServer")); - } - else - { - sendDebug(F("Attempting WiFi reconnection..."), F("WifiServer")); - } - - // Ensure clean state before reconnection - _radio->disconnect(); - _restartTime = now + RestartDelay; - _connectionState = WifiConnectionState::Restarting; + sendDebug(F("Attempting WiFi reconnection..."), F("WifiServer")); } - break; + + // Ensure clean state before reconnection + _radio->disconnect(); + _restartTime = now + RestartDelay; + _connectionState = WifiConnectionState::Restarting; + } + break; + } + case WifiConnectionState::Restarting: + if (now > _restartTime) + { + _radio->beginClient(_ssid, _password); + _connectionState = WifiConnectionState::Connecting; + _connectionStartTime = now; + _lastConnectionAttempt = now; } - case WifiConnectionState::Restarting: - if (now > _restartTime) - { - _radio->beginClient(_ssid, _password); - _connectionState = WifiConnectionState::Connecting; - _connectionStartTime = now; - _lastConnectionAttempt = now; - } - break; + break; } } diff --git a/PowerControlHub/WifiServer.h b/PowerControlHub/WifiServer.h index e42a8db..2963c6c 100644 --- a/PowerControlHub/WifiServer.h +++ b/PowerControlHub/WifiServer.h @@ -65,6 +65,7 @@ class WifiServer : public SingleLoggerSupport uint64_t _lastRSSICheck; IWifiRadio* _radio; uint64_t _restartTime; + const NetworkAuthConfig* _authConfig; // AP mode settings char _ssid[MaxSSIDLength]; char _password[MaxWiFiPasswordLength]; @@ -130,4 +131,7 @@ class WifiServer : public SingleLoggerSupport bool getIpAddress(char* buffer, const uint8_t bufferLength) const; bool getSSID(char* buffer, const uint8_t bufferLength) const; int getSignalStrength() const; + + // Set auth configuration pointer (owned by global config) + void setAuthConfig(const NetworkAuthConfig* authConfig) { _authConfig = authConfig; } }; \ No newline at end of file diff --git a/PowerControlHubApp/AppShell.xaml.cs b/PowerControlHubApp/AppShell.xaml.cs index 4cf4248..e601296 100644 --- a/PowerControlHubApp/AppShell.xaml.cs +++ b/PowerControlHubApp/AppShell.xaml.cs @@ -13,6 +13,7 @@ public AppShell() Routing.RegisterRoute(nameof(TimeSettingsPage), typeof(TimeSettingsPage)); Routing.RegisterRoute(nameof(MqttSettingsPage), typeof(MqttSettingsPage)); Routing.RegisterRoute(nameof(SdCardSettingsPage), typeof(SdCardSettingsPage)); + Routing.RegisterRoute(nameof(NetworkSecurityPage), typeof(NetworkSecurityPage)); } } } diff --git a/PowerControlHubApp/Internal/Constants.cs b/PowerControlHubApp/Internal/Constants.cs index b3b4b76..3571136 100644 --- a/PowerControlHubApp/Internal/Constants.cs +++ b/PowerControlHubApp/Internal/Constants.cs @@ -32,6 +32,8 @@ internal static class Constants public const string KeyDeviceIpAddress = "device_ip"; public const string KeyDeviceIpPort = "device_port"; public const string DefaultDeviceIpPort = "80"; + public const string KeyAuthApiKey = "auth_apikey"; + public const string KeyAuthHmacKey = "auth_hmackey"; public const string MessageNotConfigured = "Not configured — tap ⚙ to set device IP"; public const string MessageDeviceUnreachable = "Device unreachable"; public const string MessageNoActiveWarnings = "No active warnings"; @@ -199,6 +201,11 @@ internal static class Constants public const string RouteConfigSdCardSpiPins = "api/config/C4"; public const string RouteConfigSdCardInitSpeed = "api/config/C31"; public const string RouteConfigSdCardCsPin = "api/config/C32"; + public const string RouteConfigAuth = "api/config/C19"; + public const string ConfigAuthEnabledParam = "e"; + public const string ConfigAuthApiKeyParam = "k"; + public const string ConfigAuthHmacKeyParam = "h"; + public const string ConfigAuthGenerateParam = "g"; public const string RouteConfigMqttGet = "api/mqtt/{0}"; public const string RouteConfigMqttSet = "api/mqtt/{0}?v={1}"; public const string MqttConfigEnabled = "M0"; @@ -225,6 +232,11 @@ internal static class Constants public const string SdCardConfigSpiPins = "C4"; public const string SdCardConfigInitSpeed = "C31"; public const string SdCardConfigCsPin = "C32"; + public const string NetworkSecurityMsgSaveFailed = "Save failed — device unreachable"; + public const string NetworkSecurityMsgSaved = "Network security settings saved"; + public const string NetworkSecurityMsgKeysGenerated = "New keys generated and saved"; + public const string NetworkSecurityMsgRefreshed = "Refreshed"; + public const string NetworkSecurityMsgGenerateFailed = "Key generation failed — device unreachable"; public const string RouteWarnings = "api/warning/W5"; public const string ForwardSlash = "/"; public const string ResultSuccess = "success"; @@ -237,8 +249,14 @@ internal static class Constants public const string RouteTimeSettingsPage = "TimeSettingsPage"; public const string RouteMqttSettingsPage = "MqttSettingsPage"; public const string RouteSdCardSettingsPage = "SdCardSettingsPage"; + public const string RouteNetworkSecurityPage = "NetworkSecurityPage"; public const string ConnectionTypeKey = "X-Connection-Type"; public const string ConnectionTypePersistent = "persistent"; + public const string HeaderApiKey = "X-API-Key"; + public const string HeaderAuthTimestamp = "X-Auth-Timestamp"; + public const string HeaderAuthSignature = "X-Auth-Signature"; + public const string HmacSignSeparator = "\n"; + public const string HmacHexDash = "-"; public const int MaximumPermanentConnections = 2; public const int SecondsSixty = 60; public const int SecondsTen = 10; diff --git a/PowerControlHubApp/MauiProgram.cs b/PowerControlHubApp/MauiProgram.cs index 472fe8a..aceb178 100644 --- a/PowerControlHubApp/MauiProgram.cs +++ b/PowerControlHubApp/MauiProgram.cs @@ -31,7 +31,12 @@ public static MauiApp CreateMauiApp() string port = Preferences.Get(KeyDeviceIpPort, DefaultDeviceIpPort); if (!string.IsNullOrEmpty(ip) && int.TryParse(port, out int p)) + { connection.Configure(ip, p); + string apiKey = Preferences.Get(KeyAuthApiKey, string.Empty); + string hmacKey = Preferences.Get(KeyAuthHmacKey, string.Empty); + connection.ConfigureAuth(apiKey, hmacKey); + } return connection; }); @@ -51,7 +56,12 @@ public static MauiApp CreateMauiApp() string port = Preferences.Get(KeyDeviceIpPort, DefaultDeviceIpPort); if (!string.IsNullOrEmpty(ip) && int.TryParse(port, out int p)) + { connection.Configure(ip, p); + string apiKey = Preferences.Get(KeyAuthApiKey, string.Empty); + string hmacKey = Preferences.Get(KeyAuthHmacKey, string.Empty); + connection.ConfigureAuth(apiKey, hmacKey); + } return connection; }); @@ -70,7 +80,8 @@ public static MauiApp CreateMauiApp() { var service = new PowerHubService( sp.GetRequiredService(), - sp.GetRequiredService()); + sp.GetRequiredService(), + sp.GetRequiredService()); return service; }); @@ -97,6 +108,7 @@ public static MauiApp CreateMauiApp() builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); // Pages builder.Services.AddSingleton(); @@ -111,6 +123,7 @@ public static MauiApp CreateMauiApp() builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); #if DEBUG builder.Logging.AddDebug(); diff --git a/PowerControlHubApp/Messages/AuthConfigChanged.cs b/PowerControlHubApp/Messages/AuthConfigChanged.cs new file mode 100644 index 0000000..2c2466a --- /dev/null +++ b/PowerControlHubApp/Messages/AuthConfigChanged.cs @@ -0,0 +1,5 @@ +using PowerControlHubApp.Models.Json; + +namespace PowerControlHubApp.Messages; + +public record AuthConfigChanged(AuthConfigModel Config); diff --git a/PowerControlHubApp/Models/Json/AuthConfigModel.cs b/PowerControlHubApp/Models/Json/AuthConfigModel.cs new file mode 100644 index 0000000..cd1796a --- /dev/null +++ b/PowerControlHubApp/Models/Json/AuthConfigModel.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace PowerControlHubApp.Models.Json +{ + public sealed class AuthConfigModel + { + [JsonPropertyName("e")] + public bool Enabled { get; set; } + + [JsonPropertyName("k")] + public string ApiKey { get; set; } = string.Empty; + + [JsonPropertyName("h")] + public string HmacKey { get; set; } = string.Empty; + } +} diff --git a/PowerControlHubApp/PowerControlHubApp.csproj b/PowerControlHubApp/PowerControlHubApp.csproj index 0c86c4b..f48ab97 100644 --- a/PowerControlHubApp/PowerControlHubApp.csproj +++ b/PowerControlHubApp/PowerControlHubApp.csproj @@ -103,6 +103,9 @@ MSBuild:Compile + + MSBuild:Compile + MSBuild:Compile diff --git a/PowerControlHubApp/Services/ConfigConnection.cs b/PowerControlHubApp/Services/ConfigConnection.cs index 05ae6c7..ccf7b67 100644 --- a/PowerControlHubApp/Services/ConfigConnection.cs +++ b/PowerControlHubApp/Services/ConfigConnection.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using PowerControlHubApp.Messages; using PowerControlHubApp.Models; using PowerControlHubApp.Models.Json; using System.Net.Sockets; @@ -12,6 +13,7 @@ public class ConfigConnection : IConfigConnection, IDisposable { private readonly HttpClient _client; private readonly SocketsHttpHandler _handler; + private readonly DeviceAuthHandler _authHandler; private readonly Channel _queue; private readonly IMessageBus _messageBus; private readonly ILogger _log; @@ -25,8 +27,9 @@ public ConfigConnection(IMessageBus messageBus, ILogger log) _messageBus = messageBus ?? throw new ArgumentNullException(nameof(messageBus)); _log = log ?? throw new ArgumentNullException(nameof(log)); _handler = CreateHandler(); + _authHandler = new DeviceAuthHandler(_handler); - _client = new HttpClient(_handler, disposeHandler: false) + _client = new HttpClient(_authHandler, disposeHandler: false) { Timeout = TimeSpan.FromSeconds(SecondsTen) }; @@ -51,6 +54,12 @@ public void Configure(string ipAddress, int port) _client.BaseAddress = new Uri(_baseUrl + ForwardSlash); } + /// Update authentication credentials used by the handler pipeline. + public void ConfigureAuth(string apiKey, string hmacKey) + { + _authHandler.Configure(apiKey, hmacKey); + } + public bool IsConfigured => !string.IsNullOrEmpty(_baseUrl); public bool IsQueueEmpty => _queue.Reader.Count == 0; @@ -623,6 +632,109 @@ public async Task SetSdCardCsPinAsync(int pin, CancellationToken ct = defa return await SetConfigValueAsync(RouteConfigSdCardCsPin, pin, ct); } + public async Task GetAuthConfigAsync(CancellationToken ct = default) + { + try + { + string json = await _client.GetStringAsync(RouteConfigAuth, ct); + return JsonSerializer.Deserialize(json, JsonOptions) ?? new AuthConfigModel(); + } + catch + { + return new AuthConfigModel(); + } + } + + public async Task SetAuthEnabledAsync(bool enabled, CancellationToken ct = default) + { + try + { + string url = $"{RouteConfigAuth}?{ConfigAuthEnabledParam}={(enabled ? BoolTrueString : BoolFalseString)}"; + HttpResponseMessage response = await _client.PostAsync(url, null, ct); + + if (!await IsSuccessResponseAsync(response, ct)) + return false; + + await SyncAuthHandlerFromDeviceAsync(ct); + return true; + } + catch + { + return false; + } + } + + public async Task SetAuthApiKeyAsync(string apiKey, CancellationToken ct = default) + { + try + { + string url = $"{RouteConfigAuth}?{ConfigAuthApiKeyParam}={Uri.EscapeDataString(apiKey)}"; + HttpResponseMessage response = await _client.PostAsync(url, null, ct); + + if (!await IsSuccessResponseAsync(response, ct)) + return false; + + await SyncAuthHandlerFromDeviceAsync(ct); + return true; + } + catch + { + return false; + } + } + + public async Task SetAuthHmacKeyAsync(string hmacKey, CancellationToken ct = default) + { + try + { + string url = $"{RouteConfigAuth}?{ConfigAuthHmacKeyParam}={Uri.EscapeDataString(hmacKey)}"; + HttpResponseMessage response = await _client.PostAsync(url, null, ct); + + if (!await IsSuccessResponseAsync(response, ct)) + return false; + + await SyncAuthHandlerFromDeviceAsync(ct); + return true; + } + catch + { + return false; + } + } + + public async Task GenerateAuthKeysAsync(CancellationToken ct = default) + { + try + { + string url = $"{RouteConfigAuth}?{ConfigAuthGenerateParam}={BoolTrueString}"; + HttpResponseMessage response = await _client.PostAsync(url, null, ct); + + if (!await IsSuccessResponseAsync(response, ct)) + return false; + + await SyncAuthHandlerFromDeviceAsync(ct); + return true; + } + catch + { + return false; + } + } + + private async Task SyncAuthHandlerFromDeviceAsync(CancellationToken ct) + { + try + { + AuthConfigModel config = await GetAuthConfigAsync(ct); + _authHandler.Configure(config.ApiKey, config.HmacKey); + _messageBus.Publish(new AuthConfigChanged(config)); + } + catch + { + // best-effort sync; if the GET fails the handler keeps its previous state + } + } + private async Task GetConfigIntAsync(string route, CancellationToken ct) { try diff --git a/PowerControlHubApp/Services/ConfigPoller.cs b/PowerControlHubApp/Services/ConfigPoller.cs index 41aa0bb..ebd8fe1 100644 --- a/PowerControlHubApp/Services/ConfigPoller.cs +++ b/PowerControlHubApp/Services/ConfigPoller.cs @@ -225,6 +225,16 @@ private async Task RunLoopAsync(CancellationToken stoppingToken) public Task SetSdCardCsPinAsync(int pin, CancellationToken ct = default) => _connection.SetSdCardCsPinAsync(pin, ct); + public Task GetAuthConfigAsync(CancellationToken ct = default) => _connection.GetAuthConfigAsync(ct); + + public Task SetAuthEnabledAsync(bool enabled, CancellationToken ct = default) => _connection.SetAuthEnabledAsync(enabled, ct); + + public Task SetAuthApiKeyAsync(string apiKey, CancellationToken ct = default) => _connection.SetAuthApiKeyAsync(apiKey, ct); + + public Task SetAuthHmacKeyAsync(string hmacKey, CancellationToken ct = default) => _connection.SetAuthHmacKeyAsync(hmacKey, ct); + + public Task GenerateAuthKeysAsync(CancellationToken ct = default) => _connection.GenerateAuthKeysAsync(ct); + public void Dispose() { if (_cts != null) diff --git a/PowerControlHubApp/Services/DashboardConnection.cs b/PowerControlHubApp/Services/DashboardConnection.cs index 8b6006f..e39298c 100644 --- a/PowerControlHubApp/Services/DashboardConnection.cs +++ b/PowerControlHubApp/Services/DashboardConnection.cs @@ -10,6 +10,7 @@ public class DashboardConnection : IDashboardConnection, IDisposable { private readonly HttpClient _client; private readonly SocketsHttpHandler _handler; + private readonly DeviceAuthHandler _authHandler; private string _baseUrl; private bool _disposed; @@ -18,8 +19,9 @@ public class DashboardConnection : IDashboardConnection, IDisposable public DashboardConnection() { _handler = CreateHandler(); + _authHandler = new DeviceAuthHandler(_handler); - _client = new HttpClient(_handler, disposeHandler: false) + _client = new HttpClient(_authHandler, disposeHandler: false) { Timeout = TimeSpan.FromSeconds(SecondsTen) }; @@ -36,6 +38,12 @@ public void Configure(string ipAddress, int port) _client.BaseAddress = new Uri(_baseUrl + ForwardSlash); } + /// Update authentication credentials used by the handler pipeline. + public void ConfigureAuth(string apiKey, string hmacKey) + { + _authHandler.Configure(apiKey, hmacKey); + } + public bool IsConfigured => !string.IsNullOrEmpty(_baseUrl); public async Task GetSystemPinsAsync(CancellationToken ct = default) diff --git a/PowerControlHubApp/Services/DeviceAuthHandler.cs b/PowerControlHubApp/Services/DeviceAuthHandler.cs new file mode 100644 index 0000000..e626425 --- /dev/null +++ b/PowerControlHubApp/Services/DeviceAuthHandler.cs @@ -0,0 +1,142 @@ +using System.Security.Cryptography; +using System.Text; +using static PowerControlHubApp.Internal.Constants; + +namespace PowerControlHubApp.Services; + +/// +/// DelegatingHandler that adds device authentication headers to every outbound HTTP request. +/// Supports two modes (either or both may be active): +/// • API Key — adds X-API-Key header +/// • HMAC — adds X-Auth-Timestamp + X-Auth-Signature headers +/// +/// Values are read from thread-safe shared state that can be updated at runtime via +/// without disrupting the handler pipeline. +/// +public sealed class DeviceAuthHandler : DelegatingHandler +{ + // Thread-safe shared state + private string _apiKey; + private string _hmacKey; + private readonly object _lock = new(); + + public DeviceAuthHandler(HttpMessageHandler innerHandler) + : base(innerHandler) + { + _apiKey = string.Empty; + _hmacKey = string.Empty; + } + + /// Update auth credentials at runtime without replacing the handler. + public void Configure(string apiKey, string hmacKey) + { + lock (_lock) + { + _apiKey = apiKey ?? string.Empty; + _hmacKey = hmacKey ?? string.Empty; + } + } + + public bool HasCredentials + { + get + { + lock (_lock) + { + return _apiKey.Length > 0 || _hmacKey.Length > 0; + } + } + } + + /// Current API key (thread-safe read). + public string ApiKey + { + get + { + lock (_lock) + { + return _apiKey; + } + } + } + + /// Current HMAC key (thread-safe read). + public string HmacKey + { + get + { + lock (_lock) + { + return _hmacKey; + } + } + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + string apiKey; + string hmacKey; + + lock (_lock) + { + apiKey = _apiKey; + hmacKey = _hmacKey; + } + + // API Key auth (simplest) + if (apiKey.Length > 0) + { + request.Headers.TryAddWithoutValidation(HeaderApiKey, apiKey); + } + + // HMAC-SHA256 auth + if (hmacKey.Length > 0) + { + string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); + + // Build canonical signing input: timestamp + "\n" + METHOD + "\n" + path + "\n" + body + string method = request.Method.Method; + string path = request.RequestUri?.PathAndQuery ?? HmacSignSeparator; + string body = string.Empty; + + if (request.Content is not null) + { + // Attempt to read body (must be buffered). For POST requests with + // small payloads this is safe. For streaming bodies the signature + // will use an empty body. + try + { + using var ms = new MemoryStream(); + await request.Content.CopyToAsync(ms, cancellationToken); + ms.Position = 0; + body = Encoding.UTF8.GetString(ms.ToArray()); + + // Re-buffer the content so the inner handler can read it + var bufferred = new ByteArrayContent(ms.ToArray()); + foreach (var h in request.Content.Headers) + bufferred.Headers.TryAddWithoutValidation(h.Key, h.Value); + request.Content = bufferred; + } + catch + { + body = string.Empty; + } + } + + string signInput = $"{timestamp}{HmacSignSeparator}{method}{HmacSignSeparator}{path}{HmacSignSeparator}{body}"; + + byte[] keyBytes = Encoding.UTF8.GetBytes(hmacKey); + byte[] messageBytes = Encoding.UTF8.GetBytes(signInput); + + using var hmacSha = new HMACSHA256(keyBytes); + byte[] hash = hmacSha.ComputeHash(messageBytes); + + string signature = BitConverter.ToString(hash).Replace(HmacHexDash, string.Empty).ToLowerInvariant(); + + request.Headers.TryAddWithoutValidation(HeaderAuthTimestamp, timestamp); + request.Headers.TryAddWithoutValidation(HeaderAuthSignature, signature); + } + + return await base.SendAsync(request, cancellationToken); + } +} diff --git a/PowerControlHubApp/Services/IConfigConnection.cs b/PowerControlHubApp/Services/IConfigConnection.cs index 67cb5a6..d8cde22 100644 --- a/PowerControlHubApp/Services/IConfigConnection.cs +++ b/PowerControlHubApp/Services/IConfigConnection.cs @@ -58,5 +58,10 @@ public interface IConfigConnection Task SetSdCardInitSpeedAsync(int speed, CancellationToken ct = default); Task GetSdCardCsPinAsync(CancellationToken ct = default); Task SetSdCardCsPinAsync(int pin, CancellationToken ct = default); + Task GetAuthConfigAsync(CancellationToken ct = default); + Task SetAuthEnabledAsync(bool enabled, CancellationToken ct = default); + Task SetAuthApiKeyAsync(string apiKey, CancellationToken ct = default); + Task SetAuthHmacKeyAsync(string hmacKey, CancellationToken ct = default); + Task GenerateAuthKeysAsync(CancellationToken ct = default); } } diff --git a/PowerControlHubApp/Services/PowerHubService.cs b/PowerControlHubApp/Services/PowerHubService.cs index 0a92ac1..5938fec 100644 --- a/PowerControlHubApp/Services/PowerHubService.cs +++ b/PowerControlHubApp/Services/PowerHubService.cs @@ -1,3 +1,4 @@ +using PowerControlHubApp.Messages; using PowerControlHubApp.Models; using PowerControlHubApp.Models.Json; @@ -25,23 +26,31 @@ public class PowerHubService private readonly IDashboardConnection _dashboardConnection; private readonly IConfigConnection _configConnection; - public PowerHubService(IDashboardConnection dashboardConnection, IConfigConnection configConnection) + public PowerHubService(IDashboardConnection dashboardConnection, IConfigConnection configConnection, IMessageBus messageBus) { _dashboardConnection = dashboardConnection ?? throw new ArgumentNullException(nameof(dashboardConnection)); _configConnection = configConnection ?? throw new ArgumentNullException(nameof(configConnection)); + + messageBus.Subscribe(OnAuthConfigChanged); } public bool IsConfigured => _dashboardConnection.IsConfigured; public string BaseUrl => _dashboardConnection is DashboardConnection dc ? dc.BaseUrl : string.Empty; - public void Configure(string ipAddress, int port) + public void Configure(string ipAddress, int port, string apiKey = "", string hmacKey = "") { if (_dashboardConnection is DashboardConnection dc) + { dc.Configure(ipAddress, port); + dc.ConfigureAuth(apiKey ?? string.Empty, hmacKey ?? string.Empty); + } if (_configConnection is ConfigConnection cc) + { cc.Configure(ipAddress, port); + cc.ConfigureAuth(apiKey ?? string.Empty, hmacKey ?? string.Empty); + } } public Task GetSystemPinsAsync(CancellationToken ct = default) @@ -313,4 +322,38 @@ public Task SetSdCardCsPinAsync(int pin, CancellationToken ct = default) { return _configConnection.SetSdCardCsPinAsync(pin, ct); } + + public Task GetAuthConfigAsync(CancellationToken ct = default) + { + return _configConnection.GetAuthConfigAsync(ct); + } + + public Task SetAuthEnabledAsync(bool enabled, CancellationToken ct = default) + { + return _configConnection.SetAuthEnabledAsync(enabled, ct); + } + + public Task SetAuthApiKeyAsync(string apiKey, CancellationToken ct = default) + { + return _configConnection.SetAuthApiKeyAsync(apiKey, ct); + } + + public Task SetAuthHmacKeyAsync(string hmacKey, CancellationToken ct = default) + { + return _configConnection.SetAuthHmacKeyAsync(hmacKey, ct); + } + + public Task GenerateAuthKeysAsync(CancellationToken ct = default) + { + return _configConnection.GenerateAuthKeysAsync(ct); + } + + private void OnAuthConfigChanged(AuthConfigChanged msg) + { + if (_dashboardConnection is DashboardConnection dc) + dc.ConfigureAuth(msg.Config.ApiKey, msg.Config.HmacKey); + + if (_configConnection is ConfigConnection cc) + cc.ConfigureAuth(msg.Config.ApiKey, msg.Config.HmacKey); + } } diff --git a/PowerControlHubApp/ViewModels/NetworkSecurityViewModel.cs b/PowerControlHubApp/ViewModels/NetworkSecurityViewModel.cs new file mode 100644 index 0000000..15b1bf1 --- /dev/null +++ b/PowerControlHubApp/ViewModels/NetworkSecurityViewModel.cs @@ -0,0 +1,241 @@ +using PowerControlHubApp.Models.Json; +using PowerControlHubApp.Services; +using System.Windows.Input; +using static PowerControlHubApp.Internal.Constants; + +namespace PowerControlHubApp.ViewModels; + +public sealed class NetworkSecurityViewModel : BaseViewModel +{ + private string _apiKey = string.Empty; + private string _hmacKey = string.Empty; + private bool _isEnabled; + private bool _isRefreshing; + private bool _isSaving; + private bool _isGenerating; + + public NetworkSecurityViewModel(PowerHubService service, LogService log) + : base(service, log) + { + RefreshCommand = new Command(async () => await RefreshAsync()); + SaveAllCommand = new Command(async () => await SaveAllAsync()); + GenerateKeysCommand = new Command(async () => await GenerateKeysAsync()); + } + + public ICommand SaveAllCommand { get; } + + public ICommand GenerateKeysCommand { get; } + + public string ApiKey + { + get => _apiKey; + + set + { + _apiKey = value; + OnPropertyChanged(); + } + } + + public string HmacKey + { + get => _hmacKey; + + set + { + _hmacKey = value; + OnPropertyChanged(); + } + } + + public bool IsEnabled + { + get => _isEnabled; + + set + { + _isEnabled = value; + OnPropertyChanged(); + } + } + + public bool IsRefreshing + { + get => _isRefreshing; + + set + { + _isRefreshing = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(IsNotRefreshing)); + } + } + + public bool IsNotRefreshing => !_isRefreshing; + + public bool IsSaving + { + get => _isSaving; + + set + { + _isSaving = value; + OnPropertyChanged(); + } + } + + public bool IsGenerating + { + get => _isGenerating; + + set + { + _isGenerating = value; + OnPropertyChanged(); + } + } + + public bool HasStatusMessage => !string.IsNullOrEmpty(StatusMessage); + + public async Task RefreshAsync() + { + if (!Service.IsConfigured || _isRefreshing) + return; + + IsRefreshing = true; + + try + { + var config = await Service.GetAuthConfigAsync(); + + MainThread.BeginInvokeOnMainThread(() => + { + if (config != null) + { + IsEnabled = config.Enabled; + ApiKey = config.ApiKey ?? string.Empty; + HmacKey = config.HmacKey ?? string.Empty; + } + + IsConnected = true; + StatusMessage = $"{NetworkSecurityMsgRefreshed} {DateTime.Now:HH:mm:ss}"; + OnPropertyChanged(nameof(HasStatusMessage)); + }); + } + catch + { + MainThread.BeginInvokeOnMainThread(() => + { + IsConnected = false; + StatusMessage = MessageDeviceUnreachable; + OnPropertyChanged(nameof(HasStatusMessage)); + }); + } + finally + { + IsRefreshing = false; + } + } + + public async Task SaveAllAsync() + { + if (!Service.IsConfigured || _isSaving) + return; + + IsSaving = true; + bool enableFailed = false; + bool apiKeyFailed = false; + bool hmacKeyFailed = false; + + try + { + var apiKeyOk = await Service.SetAuthApiKeyAsync(ApiKey); + apiKeyFailed = !apiKeyOk; + + var hmacKeyOk = await Service.SetAuthHmacKeyAsync(HmacKey); + hmacKeyFailed = !hmacKeyOk; + + var enabledOk = await Service.SetAuthEnabledAsync(IsEnabled); + enableFailed = !enabledOk; + + await Service.SaveSettingsAsync(); + + MainThread.BeginInvokeOnMainThread(() => + { + if (enableFailed && apiKeyFailed && hmacKeyFailed) + { + StatusMessage = NetworkSecurityMsgSaveFailed; + } + else if (enableFailed || apiKeyFailed || hmacKeyFailed) + { + StatusMessage = NetworkSecurityMsgSaveFailed; + } + else + { + StatusMessage = NetworkSecurityMsgSaved; + } + + OnPropertyChanged(nameof(HasStatusMessage)); + }); + } + catch + { + MainThread.BeginInvokeOnMainThread(() => + { + StatusMessage = NetworkSecurityMsgSaveFailed; + OnPropertyChanged(nameof(HasStatusMessage)); + }); + } + finally + { + IsSaving = false; + } + } + + public async Task GenerateKeysAsync() + { + if (!Service.IsConfigured || _isGenerating) + return; + + IsGenerating = true; + + try + { + var ok = await Service.GenerateAuthKeysAsync(); + + if (ok) + { + await RefreshAsync(); + MainThread.BeginInvokeOnMainThread(() => + { + StatusMessage = NetworkSecurityMsgKeysGenerated; + OnPropertyChanged(nameof(HasStatusMessage)); + }); + } + else + { + MainThread.BeginInvokeOnMainThread(() => + { + StatusMessage = NetworkSecurityMsgGenerateFailed; + OnPropertyChanged(nameof(HasStatusMessage)); + }); + } + } + catch + { + MainThread.BeginInvokeOnMainThread(() => + { + StatusMessage = NetworkSecurityMsgGenerateFailed; + OnPropertyChanged(nameof(HasStatusMessage)); + }); + } + finally + { + IsGenerating = false; + } + } + + protected override void OnDataFetched(IndexModel index) + { + } +} diff --git a/PowerControlHubApp/ViewModels/SettingsViewModel.cs b/PowerControlHubApp/ViewModels/SettingsViewModel.cs index 93c45f2..c366d78 100644 --- a/PowerControlHubApp/ViewModels/SettingsViewModel.cs +++ b/PowerControlHubApp/ViewModels/SettingsViewModel.cs @@ -10,6 +10,8 @@ public class SettingsViewModel : BaseViewModel private readonly ThemeService _themeService; private string _ipAddress = string.Empty; private string _port = DefaultDeviceIpPort; + private string _apiKey = string.Empty; + private string _hmacKey = string.Empty; private string _selectedTheme; private string _pinsInUseText = string.Empty; private bool _isPinsRefreshing; @@ -38,6 +40,28 @@ public string Port } } + public string ApiKey + { + get => _apiKey; + + set + { + _apiKey = value; + OnPropertyChanged(); + } + } + + public string HmacKey + { + get => _hmacKey; + + set + { + _hmacKey = value; + OnPropertyChanged(); + } + } + // StatusMessage is provided by BaseViewModel public bool HasStatusMessage => !string.IsNullOrEmpty(StatusMessage); @@ -109,6 +133,8 @@ public SettingsViewModel(PowerHubService service, ThemeService themeService, Log IpAddress = Preferences.Get(KeyDeviceIpAddress, string.Empty); Port = Preferences.Get(KeyDeviceIpPort, DefaultDeviceIpPort); + ApiKey = Preferences.Get(KeyAuthApiKey, string.Empty); + HmacKey = Preferences.Get(KeyAuthHmacKey, string.Empty); _selectedTheme = ThemeService.Current; } @@ -161,10 +187,15 @@ private void Save() return; } + string apiKey = ApiKey.Trim(); + string hmacKey = HmacKey.Trim(); + Preferences.Set(KeyDeviceIpAddress, ip); Preferences.Set(KeyDeviceIpPort, port.ToString()); + Preferences.Set(KeyAuthApiKey, apiKey); + Preferences.Set(KeyAuthHmacKey, hmacKey); - Service.Configure(ip, port); + Service.Configure(ip, port, apiKey, hmacKey); StatusMessage = $"Saved. Connecting to {ip}:{port}"; } diff --git a/PowerControlHubApp/ViewModels/SystemViewModel.cs b/PowerControlHubApp/ViewModels/SystemViewModel.cs index 9ef90c1..0df511a 100644 --- a/PowerControlHubApp/ViewModels/SystemViewModel.cs +++ b/PowerControlHubApp/ViewModels/SystemViewModel.cs @@ -70,6 +70,8 @@ public bool IsPinsRefreshing public ICommand NavigateToSdCardSettingsCommand { get; } + public ICommand NavigateToNetworkSecurityCommand { get; } + public SystemViewModel(PowerHubService service, LogService log) : base(service, log) { @@ -79,6 +81,7 @@ public SystemViewModel(PowerHubService service, LogService log) NavigateToTimeSettingsCommand = new Command(async () => await Shell.Current.GoToAsync(RouteTimeSettingsPage)); NavigateToMqttSettingsCommand = new Command(async () => await Shell.Current.GoToAsync(RouteMqttSettingsPage)); NavigateToSdCardSettingsCommand = new Command(async () => await Shell.Current.GoToAsync(RouteSdCardSettingsPage)); + NavigateToNetworkSecurityCommand = new Command(async () => await Shell.Current.GoToAsync(RouteNetworkSecurityPage)); } protected override void OnDataFetched(IndexModel index) diff --git a/PowerControlHubApp/Views/NetworkSecurityPage.xaml b/PowerControlHubApp/Views/NetworkSecurityPage.xaml new file mode 100644 index 0000000..2dd5f19 --- /dev/null +++ b/PowerControlHubApp/Views/NetworkSecurityPage.xaml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +