Implement role-aware HTTPS and SSH authentication

This commit is contained in:
2026-08-30 01:31:05 +02:00
parent cd235445c7
commit 0c058b6a8f
16 changed files with 707 additions and 331 deletions
+3 -3
View File
@@ -14,7 +14,7 @@ ESP32-S3 firmware for a secure, multi-transport RS-232 adapter. It operates one
## Development status ## Development status
Hardware characterization, the serial core, USB CDC-ACM, Wi-Fi, HTTPS/WebSocket, SSH serial transport, and the local display/control interface are implemented. Phase 8A adds the bounded role-based user database and physical UART0 user administration; target-hardware validation and the later HTTPS/SSH authentication cutover are still pending. See the [Roadmap](docs/roadmap.md) for phase status and validation details. Hardware characterization, the serial core, USB CDC-ACM, Wi-Fi, HTTPS/WebSocket, SSH serial transport, and the local display/control interface are implemented. Phase 8A's bounded role-based user database and UART0 administration are complete. Phase 8B integrates role-aware HTTPS passwords, SSH passwords/public keys, and per-account session revocation; target-hardware validation is pending. See the [Roadmap](docs/roadmap.md) for phase status and validation details.
## Documentation ## Documentation
@@ -72,11 +72,11 @@ The firmware provides an interactive UART0 console at `serial-tool>`. Run `help`
The console supports session history, line editing, cursor movement, and hierarchical Tab completion. After an unattended boot, attach an ANSI-capable terminal and press Enter once to enable enhanced editing; this avoids blocking while no terminal is attached. The console supports session history, line editing, cursor movement, and hierarchical Tab completion. After an unattended boot, attach an ANSI-capable terminal and press Enter once to enable enhanced editing; this avoids blocking while no terminal is attached.
Serial configuration and Wi-Fi edits remain in RAM until explicitly saved with `serial save` or `wifi save`. During Phase 8A, retrieve the legacy HTTPS/SSH network credential only from physical UART0 with `web credentials show`; manage the new role-based accounts with the `user` command group. Serial configuration and Wi-Fi edits remain in RAM until explicitly saved with `serial save` or `wifi save`. Manage role-based HTTPS/SSH passwords and SSH public keys with the physical UART0 `user` command group. `web credentials show` now exposes only the legacy migration/recovery credential, not an active Phase 8B network login.
## Security notes ## Security notes
The HTTPS interface uses a device-specific self-signed certificate and HTTP Basic authentication over TLS; there is no plaintext HTTP or TCP serial listener. During Phase 8A, HTTPS and SSH continue to use the legacy shared `admin` credential while role-aware password/public-key authentication remains scheduled for Phase 8B. New user passwords are stored as salted PBKDF2-HMAC-SHA256 verifiers, but the legacy network password, HTTPS private key, SSH private key, and Wi-Fi credentials remain recoverable from unencrypted application-owned NVS blobs. Offline password guessing and stale append-oriented flash copies also remain possible. The reserved `nvs_key` partition does not enable encryption. Do not treat this firmware as resistant to physical flash or RAM extraction until the planned hardening work is complete. The HTTPS interface uses a device-specific self-signed certificate and role-aware HTTP Basic authentication over TLS; there is no plaintext HTTP or TCP serial listener. SSH accepts role-based passwords and authorized Ed25519/ECDSA P-256 public keys. User passwords are stored as salted PBKDF2-HMAC-SHA256 verifiers, but the legacy recovery password, HTTPS private key, SSH private key, and Wi-Fi credentials remain recoverable from unencrypted application-owned NVS blobs. Offline password guessing and stale append-oriented flash copies also remain possible. The reserved `nvs_key` partition does not enable encryption. Do not treat this firmware as resistant to physical flash or RAM extraction until the planned hardening work is complete.
## License ## License
+8 -8
View File
@@ -10,7 +10,7 @@ Use these commands from the UART0 `serial-tool>` administration console. Run `he
| `reboot` | Drain console output briefly and restart the ESP32. | | `reboot` | Drain console output briefly and restart the ESP32. |
| `status` | Show quick MAX3243 signal state. | | `status` | Show quick MAX3243 signal state. |
## Role-based users (Phase 8A) ## Role-based users
| Command | Description | | Command | Description |
|---|---| |---|---|
@@ -31,9 +31,9 @@ Use these commands from the UART0 `serial-tool>` administration console. Run `he
Usernames must match `[a-z][a-z0-9_-]{0,15}`. Passwords contain 1264 printable ASCII characters. The fixed database supports eight users and three SSH keys per user; initial key types are `ssh-ed25519` and `ecdsa-sha2-nistp256`. A key may belong to only one account. Password verifiers, salts, raw key blobs, and passwords are absent from ordinary status output. `Ctrl-C` cancels a password or key prompt, and generated passwords are shown once. Usernames must match `[a-z][a-z0-9_-]{0,15}`. Passwords contain 1264 printable ASCII characters. The fixed database supports eight users and three SSH keys per user; initial key types are `ssh-ed25519` and `ecdsa-sha2-nistp256`. A key may belong to only one account. Password verifiers, salts, raw key blobs, and passwords are absent from ordinary status output. `Ctrl-C` cancels a password or key prompt, and generated passwords are shown once.
On the first Phase 8A boot, the existing shared `admin` HTTPS/SSH credential is imported as a role-`user` account, not silently granted administrator rights. Run `user bootstrap` from physical UART0 to establish the administrator. Before bootstrap, `web credentials rotate --force` and `web reset --force` synchronize the migrated verifier; reboot reconciliation retries an interrupted synchronization. After bootstrap, the legacy HTTPS/SSH credential and user-database passwords are intentionally separate until Phase 8B performs the authentication cutover. SSH public-key login is likewise enabled in Phase 8B, not Phase 8A. On the first Phase 8A boot, the old shared `admin` credential is imported as a role-`user` account, not silently granted administrator rights. Run `user bootstrap` from physical UART0 to establish the administrator. Phase 8B now authenticates HTTPS and SSH passwords through this database and enables stored SSH public keys. Before bootstrap, `web credentials rotate --force` and `web reset --force` synchronize the migrated verifier; after bootstrap, that legacy credential is recovery-only and does not authenticate or alter role-based users.
`user recover --force` is a destructive physical recovery operation and succeeds only while the database is unavailable. It replaces the user blob with one role-`user` account derived from the current legacy credential; run `user bootstrap` afterward. It does not erase unrelated NVS data. `user recover --force` is a destructive physical recovery operation and succeeds only while the database is unavailable. It replaces the user blob with one role-`user` account derived from the current legacy credential; run `user bootstrap` afterward. It does not erase unrelated NVS data. Successful password, role, key, bootstrap, and delete operations invalidate only that username's outstanding WebSocket tickets and active WebSocket/SSH sessions; unrelated users remain connected.
## Local display ## Local display
@@ -115,13 +115,13 @@ Opening `/dev/ttyACM*` with DTR asserted creates the `usb-cdc` broker client, st
| `web status` | Show HTTPS and WebSocket state. | | `web status` | Show HTTPS and WebSocket state. |
| `web start` / `web stop` | Start or stop HTTPS service. | | `web start` / `web stop` | Start or stop HTTPS service. |
| `web counters` / `web clear-counters` | Show or clear web counters. | | `web counters` / `web clear-counters` | Show or clear web counters. |
| `web credentials show` | Display the physical-console-only legacy Phase 8A network credential. | | `web credentials show` | Display the physical-console-only legacy migration/recovery credential; it is not a Phase 8B network login. |
| `web credentials rotate --force` | Replace the legacy credential, apply pre-bootstrap migration synchronization, and disconnect web and SSH sessions. | | `web credentials rotate --force` | Replace the legacy recovery credential and synchronize the migrated pre-bootstrap account only. |
| `web certificate info` | Display certificate identity and fingerprint. | | `web certificate info` | Display certificate identity and fingerprint. |
| `web certificate rotate --force` | Replace the HTTPS certificate and private key. | | `web certificate rotate --force` | Replace the HTTPS certificate and private key. |
| `web reset --force` | Explicitly replace missing, incompatible, or damaged legacy credentials and web material. | | `web reset --force` | Explicitly replace missing, incompatible, or damaged legacy credentials and web material. |
HTTPS listens on port 443 only. During Phase 8A, authenticate to `https://<device-address>/` as `admin` with the legacy credential shown on UART0; role-aware user-database authentication begins in Phase 8B. The device serves its vendored xterm.js terminal without Internet access. Browser sessions use binary WebSocket frames and follow the broker's one-writer rule. HTTPS listens on port 443 only. Authenticate with any current user-database username/password; both `user` and `admin` roles receive the existing status and browser-terminal interface. The device serves vendored xterm.js without Internet access. Browser sessions use one-time account-bound tickets, binary WebSocket frames, and the broker's one-writer rule. Account mutations revoke only that account's tickets and sessions.
## SSH serial transport ## SSH serial transport
@@ -130,14 +130,14 @@ HTTPS listens on port 443 only. During Phase 8A, authenticate to `https://<devic
| `ssh` / `ssh help` | Show SSH command usage. | | `ssh` / `ssh help` | Show SSH command usage. |
| `ssh status` | Show service state and resource information. | | `ssh status` | Show service state and resource information. |
| `ssh start` / `ssh stop` | Start or stop the SSH server. | | `ssh start` / `ssh stop` | Start or stop the SSH server. |
| `ssh sessions` | List active SSH sessions and broker roles. | | `ssh sessions` | List active SSH sessions with account, user role, authentication method, and broker role. |
| `ssh disconnect <session-id>` | Disconnect one SSH session. | | `ssh disconnect <session-id>` | Disconnect one SSH session. |
| `ssh counters` / `ssh clear-counters` | Show or clear SSH counters. | | `ssh counters` / `ssh clear-counters` | Show or clear SSH counters. |
| `ssh host-key info` | Display the OpenSSH host-key fingerprint. | | `ssh host-key info` | Display the OpenSSH host-key fingerprint. |
| `ssh host-key rotate --force` | Replace the persistent SSH host key. | | `ssh host-key rotate --force` | Replace the persistent SSH host key. |
| `ssh reset --force` | Explicitly replace invalid or missing SSH material. | | `ssh reset --force` | Explicitly replace invalid or missing SSH material. |
During Phase 8A, SSH listens on port 22 and uses the same legacy `admin` credential as HTTPS, but a separate host key. It accepts password-authenticated interactive shell/PTY serial sessions only. Stored per-user public keys and roles become active in Phase 8B/8C; Phase 8A does not yet provide public-key login or the administrative SSH shell. SSH does not provide `exec`, SFTP, SCP, forwarding, or subsystems. Verify the fingerprint from `ssh host-key info` before accepting an SSH host key. SSH listens on port 22 and accepts user-database passwords plus stored `ssh-ed25519` and `ecdsa-sha2-nistp256` public keys. wolfSSH verifies key possession after the database authorizes the username/key pair; unsigned key probes do not complete authentication. Both `user` and `admin` currently receive the same broker-backed interactive shell/PTY serial stream. The administrative SSH shell arrives in Phase 8C. SSH does not provide `exec`, SFTP, SCP, forwarding, or subsystems. Verify the host fingerprint from `ssh host-key info` before connecting.
## Hardware diagnostics ## Hardware diagnostics
+36
View File
@@ -269,6 +269,42 @@ Because NVS is not yet encrypted, this test validates logical verifier storage r
With the maximum supported mix of USB CDC, WebSocket, and SSH serial clients active, repeat user list/show and several UART0 mutations. Confirm password prompts remain physical-UART-only, no user operation acquires a broker client or writer lease, existing Phase 8A network authentication and revocation behavior remains functional, and serial/broker counters show no unexplained loss. Record `memory`, `web counters`, and `ssh counters` before and after the run. With the maximum supported mix of USB CDC, WebSocket, and SSH serial clients active, repeat user list/show and several UART0 mutations. Confirm password prompts remain physical-UART-only, no user operation acquires a broker client or writer lease, existing Phase 8A network authentication and revocation behavior remains functional, and serial/broker counters show no unexplained loss. Record `memory`, `web counters`, and `ssh counters` before and after the run.
## Phase 8B role-aware HTTPS and SSH authentication
Complete the Phase 8A bootstrap first and retain two disposable accounts, one `user` and one `admin`. Phase 8B gives both roles the existing serial/status interface; the administrative SSH shell is not expected until Phase 8C.
### 1. HTTPS passwords and fail-closed behavior
For both roles, authenticate to `/`, `/api/status`, and the browser terminal with the account password. Confirm the full 16-character username and a 64-character password work, while absent, malformed, oversized, wrong-user, and wrong-password Basic headers fail without exposing which component was wrong. After bootstrap, confirm the value from `web credentials show` no longer authenticates unless it independently equals a current user password.
On a disposable malformed-database image, confirm HTTPS returns authentication-service unavailable and never falls back to the legacy credential. Recover through physical UART0 only. Verify `web status` reports user-database authentication and active WebSocket sessions show account, user role, and password method without secrets.
### 2. SSH password and public-key login
For both `user` and `admin`, verify password SSH login reaches the same broker-backed serial stream as before. Confirm `ssh sessions` shows the account, user role, password method, and writer/observer role. An administrator must not receive UART0 commands yet.
Import one Ed25519 key and one ECDSA P-256 key through `user key add`, then verify each matching private key authenticates without a password and `ssh sessions` reports `public-key`. Confirm an unregistered key, wrong username, unsupported RSA key, certificate key, and three failed completed attempts are rejected. Normal client probe-then-sign behavior must consume one completed attempt, not two; unsigned probes alone must never create an authenticated session or broker client.
### 3. WebSocket ticket binding
Authenticate separately as two users and request tickets. Confirm each ticket is accepted exactly once, expires after 30 seconds, and cannot be reused or altered. Change one account after ticket issuance but before WebSocket connection; its ticket must fail closed without creating a broker client, while the other account's ticket still works. Repeat with delete followed by recreation of the same username and confirm the old ticket remains invalid.
### 4. Targeted session revocation
Connect simultaneous WebSocket and SSH sessions for two different users and place one affected session in writer state. One mutation at a time, exercise password change, role change, SSH-key add/delete/clear, and account deletion. Required results:
1. Outstanding tickets and active sessions for the changed username are revoked promptly and any writer lease is released.
2. The stale session cannot inject additional serial bytes or reacquire writer ownership.
3. The unrelated user's WebSocket/SSH sessions remain connected and retain their expected broker roles.
4. New authentication succeeds only with the updated password/key/role state.
5. Deleting and recreating the same username does not revive old tickets or sessions.
Run `web status`, `ssh sessions`, `web counters`, `ssh counters`, and `broker clients` around each mutation. Also rotate `web credentials rotate --force` after administrator bootstrap and confirm it changes only recovery material: role-based HTTPS/SSH sessions remain connected and user passwords are unchanged. `web reset --force` restarts HTTPS because the TLS identity changes, but must not revoke unrelated SSH sessions.
### 5. Concurrency regression
With USB CDC, two role-based network users, one WebSocket terminal, one SSH terminal, and UART1 traffic active, alternate writer ownership and mutate one account. Confirm binary transparency, observer isolation, bounded authentication/handshake behavior, UART0 responsiveness, and no unexpected disconnect of the unaffected user. Record memory, broker, web, SSH, and serial counters before and after. Repeat after reboot to verify passwords, roles, keys, and authentication methods persist.
## Configuration A: data and handshake pairs ## Configuration A: data and handshake pairs
Connect the following pairs: Connect the following pairs:
+12 -9
View File
@@ -38,7 +38,7 @@ These constraints apply across all phases:
| 5B | Offline xterm.js WebSocket serial terminal | **Complete** | | 5B | Offline xterm.js WebSocket serial terminal | **Complete** |
| 6 | Authenticated SSH serial transport | **Complete** | | 6 | Authenticated SSH serial transport | **Complete** |
| 7 | Local display and button interface | **In progress (7E validation)** | | 7 | Local display and button interface | **In progress (7E validation)** |
| 8 | Role-based users and administrative access | **In progress (8A validation)** | | 8 | Role-based users and administrative access | **In progress (8B validation)** |
| 9 | Security and production hardening | **Planned** | | 9 | Security and production hardening | **Planned** |
| 10 | Authenticated, rollback-capable OTA | **Planned** | | 10 | Authenticated, rollback-capable OTA | **Planned** |
| 11 | BLE serial transport and provisioning evaluation | **Planned** | | 11 | BLE serial transport and provisioning evaluation | **Planned** |
@@ -179,7 +179,7 @@ The software-crypto build no longer reproduces the HTTPD watchdog stall. This va
## Current and planned phases ## Current and planned phases
The order below is the current plan. Phase 7 is in progress, Phase 8A is implemented and awaiting target-hardware validation, and later work remains planned or under evaluation. Detailed requirements should be finalized at the start of each phase, and optional features must not weaken the completed serial and recovery paths. The order below is the current plan. Phase 7 is in progress, Phase 8A is complete, Phase 8B is implemented and awaiting target-hardware validation, and later work remains planned or under evaluation. Detailed requirements should be finalized at the start of each phase, and optional features must not weaken the completed serial and recovery paths.
### Phase 7 — Local display and buttons ### Phase 7 — Local display and buttons
@@ -244,19 +244,22 @@ Replace the single shared network credential with a bounded, persistent user sys
Implementation sequence: Implementation sequence:
1. **Phase 8A — User database and UART0 administration — Implemented; validation pending** 1. **Phase 8A — User database and UART0 administration — Complete**
- A versioned `user_db` NVS blob holds at most eight accounts with unique lowercase usernames, `user`/`admin` roles, random account IDs, per-account authentication generations, and fixed storage bounds. The final administrator cannot be deleted or demoted. - A versioned `user_db` NVS blob holds at most eight accounts with unique lowercase usernames, `user`/`admin` roles, random account IDs, per-account authentication generations, and fixed storage bounds. The final administrator cannot be deleted or demoted.
- Passwords use random 16-byte salts and PBKDF2-HMAC-SHA256 verifiers rather than recoverable plaintext. UART0 provides bounded no-echo entry, confirmation, and one-time 24-character generated passwords for bootstrap, create, and password-change operations. Valid authentication attempts perform one real or dummy PBKDF2 outside the database mutex and revalidate the account generation before returning a principal. - Passwords use random 16-byte salts and PBKDF2-HMAC-SHA256 verifiers rather than recoverable plaintext. UART0 provides bounded no-echo entry, confirmation, and one-time 24-character generated passwords for bootstrap, create, and password-change operations. Valid authentication attempts perform one real or dummy PBKDF2 outside the database mutex and revalidate the account generation before returning a principal.
- Each account accepts up to three bounded OpenSSH public keys. Initial import supports Ed25519 and ECDSA P-256, validates key structure and P-256 points, rejects a key already assigned to any account, and exposes only key type plus SHA-256 fingerprint in snapshots and status output. - Each account accepts up to three bounded OpenSSH public keys. Initial import supports Ed25519 and ECDSA P-256, validates key structure and P-256 points, rejects a key already assigned to any account, and exposes only key type plus SHA-256 fingerprint in snapshots and status output.
- On first boot after upgrade, the existing shared `admin` network credential is transactionally imported as a role-`user` account; it is never silently elevated. `user bootstrap` sets a new password and promotes that account to `admin`. Until bootstrap, legacy web credential rotation/reset synchronizes the imported verifier and boot reconciliation repairs an interrupted second NVS commit. After bootstrap, Phase 8A deliberately leaves the legacy HTTPS/SSH credential separate until Phase 8B switches network authentication to the common database. - On first boot after upgrade, the existing shared `admin` network credential is transactionally imported as a role-`user` account; it is never silently elevated. `user bootstrap` sets a new password and promotes that account to `admin`. Until bootstrap, legacy web credential rotation/reset synchronizes the imported verifier and boot reconciliation repairs an interrupted second NVS commit. After bootstrap, Phase 8A deliberately leaves the legacy HTTPS/SSH credential separate until Phase 8B switches network authentication to the common database.
- `user recover --force` remains registered even when a malformed or incompatible user blob prevents normal initialization. From physical UART0 it explicitly replaces only the user-database blob with a role-`user` account derived from the current legacy network credential; it never automatically erases shared NVS. - `user recover --force` remains registered even when a malformed or incompatible user blob prevents normal initialization. From physical UART0 it explicitly replaces only the user-database blob with a role-`user` account derived from the current legacy network credential; it never automatically erases shared NVS.
- NVS remains unencrypted. Verifiers improve password-at-rest handling, but offline guessing remains possible and append-oriented flash may retain historical plaintext legacy credentials until the partition or flash is physically erased. - NVS remains unencrypted. Verifiers improve password-at-rest handling, but offline guessing remains possible and append-oriented flash may retain historical plaintext legacy credentials until the partition or flash is physically erased.
- Pending target-hardware validation covers migration and reboot persistence, CRUD/capacity/final-admin invariants, hidden and generated passwords, malformed/duplicate/capacity-limited key imports, legacy-rotation reconciliation, explicit recovery, NVS failures, and regression of existing HTTPS/SSH access. - Target-hardware basics for migration, bootstrap, CRUD, persistence, and command behavior were validated before beginning Phase 8B. The full fault-injection matrix remains a regression suite for later hardening.
2. **Phase 8B — Role-aware HTTPS and SSH authentication — Planned** 2. **Phase 8B — Role-aware HTTPS and SSH authentication — Implemented; validation pending**
- Authenticate HTTPS and SSH passwords through the common user database and retain a secret-free principal containing account ID, role, authentication method, and credential generation. - HTTPS Basic and SSH password authentication now use the common user database and retain a secret-free principal containing account ID, username, role, authentication method, and credential generation. Authentication fails closed when the database is unavailable; both `user` and `admin` roles retain the existing serial/status experience.
- Add SSH public-key authentication while retaining password authentication. wolfSSH authorizes the stored username/key pair and remains responsible for cryptographic signature verification; unsigned key probes never count as completed authentication. - SSH advertises password and public-key authentication only. The application authorizes stored Ed25519 or ECDSA P-256 username/key pairs, while wolfSSH verifies possession. Authorized unsigned probes produce only the protocol `PK_OK` response; a principal becomes authenticated only from wolfSSH's post-signature result callback and is checked again before broker admission.
- Bind WebSocket tickets and active SSH/WebSocket sessions to account generations so password, role, key, disable, or delete operations can revoke only the affected account. - WebSocket tickets are one-time, principal-bound bearer values. Tickets are consumed before currentness validation, and active WebSocket/SSH sessions retain copied principals. Currentness is checked around broker admission, before WebSocket input, and periodically for idle/output sessions.
- Preserve the current serial terminal, status, writer/observer behavior, and hidden administrative controls for normal `user` accounts. - Successful password, role, key, bootstrap, delete, and legacy pre-bootstrap synchronization mutations synchronously invalidate only the affected username's tickets and sessions. Per-account generations plus ongoing currentness checks provide fail-safe revocation for changed/deleted accounts while unrelated users remain connected.
- Secret-free UART0 session snapshots show account, role, and authentication method. Transport slot generations remain separate from account authentication generations to preserve ABA-safe task/work ownership.
- The legacy `web_sec` credential no longer authenticates HTTPS or SSH. It remains only for first migration and explicit physical recovery; rotating it after bootstrap does not disconnect or alter role-based users. TLS certificate/private-key handling remains in `web_security`.
- Pending target-hardware validation covers both roles and password lengths, both supported SSH key types and probe/signature behavior, ticket binding/reuse/expiry, targeted password/role/key/delete revocation, username recreation, concurrent users/transports, database-unavailable fail-closed behavior, and preservation of normal serial writer/observer operation.
3. **Phase 8C — SSH administrative shell — Planned** 3. **Phase 8C — SSH administrative shell — Planned**
- Route authenticated `admin` SSH shell sessions to the same registered administrative command set as UART0, without creating a broker client. Normal users continue to receive the existing broker-backed serial stream. - Route authenticated `admin` SSH shell sessions to the same registered administrative command set as UART0, without creating a broker client. Normal users continue to receive the existing broker-backed serial stream.
- Serialize command parsing safely because ESP-IDF console internals are process-global. Use bounded per-session input/output queues and a separate command worker; only the SSH owner task may call wolfSSH APIs. - Serialize command parsing safely because ESP-IDF console internals are process-global. Use bounded per-session input/output queues and a separate command worker; only the SSH owner task may call wolfSSH APIs.
+2 -2
View File
@@ -127,7 +127,7 @@ void app_main(void)
"HTTPS security material unavailable (%s); use UART0 'web reset --force' to replace it", "HTTPS security material unavailable (%s); use UART0 'web reset --force' to replace it",
esp_err_to_name(web_security_error)); esp_err_to_name(web_security_error));
} else { } else {
ESP_LOGI(TAG, "Using %s HTTPS identity and administrative credential", ESP_LOGI(TAG, "Using %s HTTPS identity and legacy recovery credential",
web_security_source == WEB_SECURITY_LOAD_STORED ? "stored" : "newly generated"); web_security_source == WEB_SECURITY_LOAD_STORED ? "stored" : "newly generated");
} }
@@ -151,7 +151,7 @@ void app_main(void)
secure_wipe(&legacy_credentials, sizeof(legacy_credentials)); secure_wipe(&legacy_credentials, sizeof(legacy_credentials));
secure_wipe(&legacy, sizeof(legacy)); secure_wipe(&legacy, sizeof(legacy));
if (user_database_error != ESP_OK) { if (user_database_error != ESP_OK) {
ESP_LOGE(TAG, "User database unavailable: %s; current network authentication remains active", ESP_LOGE(TAG, "User database unavailable: %s; HTTPS and SSH authentication will fail closed; use UART0 'user recover --force'",
esp_err_to_name(user_database_error)); esp_err_to_name(user_database_error));
} else { } else {
ESP_LOGI(TAG, "Using %s user database", ESP_LOGI(TAG, "Using %s user database",
+19 -14
View File
@@ -14,7 +14,7 @@
#include "secure_random.h" #include "secure_random.h"
#include "ssh_security.h" #include "ssh_security.h"
#include "ssh_transport.h" #include "ssh_transport.h"
#include "web_security.h" #include "user_database.h"
static void print_usage(void) static void print_usage(void)
{ {
@@ -43,6 +43,13 @@ static const char *state_name(ssh_transport_session_state_t state)
} }
} }
static const char *auth_method_name(user_auth_method_t method)
{
return method == USER_AUTH_METHOD_PASSWORD
? "password"
: method == USER_AUTH_METHOD_SSH_PUBLIC_KEY ? "public-key" : "unknown";
}
static int print_sessions(const ssh_transport_snapshot_t *snapshot) static int print_sessions(const ssh_transport_snapshot_t *snapshot)
{ {
printf("SSH sessions: active=%" PRIu32 "/%u\n", printf("SSH sessions: active=%" PRIu32 "/%u\n",
@@ -52,10 +59,18 @@ static int print_sessions(const ssh_transport_snapshot_t *snapshot)
if (!session->active) { if (!session->active) {
continue; continue;
} }
printf(" id=%" PRIu32 " slot=%u peer=%s state=%s auth=%s broker=%" PRIu32 printf(" id=%" PRIu32 " slot=%u peer=%s state=%s auth=%s account=%s"
" role=%s rx-pending=%s tx-pending=%s closing=%s\n", " user-role=%s method=%s broker=%" PRIu32
" broker-role=%s rx-pending=%s tx-pending=%s closing=%s\n",
session->session_id, (unsigned int)index, session->peer, session->session_id, (unsigned int)index, session->peer,
state_name(session->state), session->authenticated ? "yes" : "no", state_name(session->state), session->authenticated ? "yes" : "no",
session->principal_valid ? session->username : "-",
session->principal_valid
? user_role_to_string(session->user_role)
: "-",
session->principal_valid
? auth_method_name(session->auth_method)
: "-",
session->broker_client_id, session->broker_client_id,
session->broker_client_id == SESSION_BROKER_NO_CLIENT session->broker_client_id == SESSION_BROKER_NO_CLIENT
? "unattached" ? "unattached"
@@ -76,23 +91,13 @@ static int show_status(bool sessions_only)
return 1; return 1;
} }
if (!sessions_only) { if (!sessions_only) {
char username[WEB_SECURITY_USERNAME_CAPACITY + 1U] = {0};
size_t username_length = 0U;
esp_err_t username_error = web_security_copy_username(
username, sizeof(username), &username_length);
printf("SSH: initialized=%s running=%s transitioning=%s port=%u last-error=%s\n", printf("SSH: initialized=%s running=%s transitioning=%s port=%u last-error=%s\n",
snapshot.initialized ? "yes" : "no", snapshot.initialized ? "yes" : "no",
snapshot.running ? "yes" : "no", snapshot.running ? "yes" : "no",
snapshot.transitioning ? "yes" : "no", snapshot.transitioning ? "yes" : "no",
(unsigned int)snapshot.port, (unsigned int)snapshot.port,
esp_err_to_name(snapshot.last_error)); esp_err_to_name(snapshot.last_error));
if (username_error == ESP_OK) { printf("Authentication: role-based password and SSH public key via user database\n");
printf("Authentication: SSH password, username=%.*s, shared with HTTPS\n",
(int)username_length, username);
} else {
printf("Administrative credentials unavailable: %s\n",
esp_err_to_name(username_error));
}
printf("Admission: shell/PTY only; exec, subsystem, forwarding, SCP, and SFTP disabled\n"); printf("Admission: shell/PTY only; exec, subsystem, forwarding, SCP, and SFTP disabled\n");
printf("Owner task: core=%" PRId32 " stack=%" PRIu32 printf("Owner task: core=%" PRId32 " stack=%" PRIu32
" minimum-free=%" PRIu32 " bytes\n", " minimum-free=%" PRIu32 " bytes\n",
+264 -36
View File
@@ -1,5 +1,5 @@
/* SPDX-License-Identifier: GPL-3.0-only */ /* SPDX-License-Identifier: GPL-3.0-only */
/* Password-authenticated SSH stream transport with two fixed broker sessions. */ /* Role-aware SSH stream transport with two fixed broker sessions. */
#include "ssh_transport.h" #include "ssh_transport.h"
@@ -22,7 +22,7 @@
#include "secure_random.h" #include "secure_random.h"
#include "serial_service.h" #include "serial_service.h"
#include "ssh_security.h" #include "ssh_security.h"
#include "web_security.h" #include "user_database.h"
#include <wolfssl/wolfcrypt/memory.h> #include <wolfssl/wolfcrypt/memory.h>
#include <wolfssl/wolfcrypt/random.h> #include <wolfssl/wolfcrypt/random.h>
#include <wolfssh/ssh.h> #include <wolfssh/ssh.h>
@@ -57,6 +57,10 @@ typedef struct {
int socket_fd; int socket_fd;
WOLFSSH *ssh; WOLFSSH *ssh;
session_broker_client_id_t broker_client_id; session_broker_client_id_t broker_client_id;
user_principal_t principal;
user_principal_t pending_principal;
bool principal_valid;
bool pending_principal_valid;
bool authenticated; bool authenticated;
bool shell_requested; bool shell_requested;
uint8_t authentication_attempts; uint8_t authentication_attempts;
@@ -126,6 +130,7 @@ static void publish_slot(const ssh_slot_t *slot, size_t slot_index)
ssh_transport_session_snapshot_t snapshot = { ssh_transport_session_snapshot_t snapshot = {
.active = slot->state != SSH_TRANSPORT_SESSION_FREE, .active = slot->state != SSH_TRANSPORT_SESSION_FREE,
.authenticated = slot->authenticated, .authenticated = slot->authenticated,
.principal_valid = slot->principal_valid,
.writer = slot->writer, .writer = slot->writer,
.close_requested = slot->close_requested, .close_requested = slot->close_requested,
.rx_pending = slot->rx_length > slot->rx_offset, .rx_pending = slot->rx_length > slot->rx_offset,
@@ -135,7 +140,15 @@ static void publish_slot(const ssh_slot_t *slot, size_t slot_index)
.socket_fd = slot->socket_fd, .socket_fd = slot->socket_fd,
.broker_client_id = slot->broker_client_id, .broker_client_id = slot->broker_client_id,
.state = slot->state, .state = slot->state,
.user_role = slot->principal_valid ? slot->principal.role : USER_ROLE_USER,
.auth_method = slot->principal_valid
? slot->principal.method
: USER_AUTH_METHOD_PASSWORD,
}; };
if (slot->principal_valid) {
memcpy(snapshot.username, slot->principal.username,
slot->principal.username_length);
}
memcpy(snapshot.peer, slot->peer, sizeof(snapshot.peer)); memcpy(snapshot.peer, slot->peer, sizeof(snapshot.peer));
taskENTER_CRITICAL(&s_lock); taskENTER_CRITICAL(&s_lock);
@@ -223,7 +236,111 @@ static int allowed_auth_types(WOLFSSH *ssh, void *context)
{ {
(void)ssh; (void)ssh;
(void)context; (void)context;
return WOLFSSH_USERAUTH_PASSWORD; return WOLFSSH_USERAUTH_PASSWORD | WOLFSSH_USERAUTH_PUBLICKEY;
}
static void clear_pending_principal(ssh_slot_t *slot)
{
if (slot != NULL) {
secure_wipe(&slot->pending_principal, sizeof(slot->pending_principal));
slot->pending_principal_valid = false;
}
}
static bool complete_authentication_attempt(ssh_slot_t *slot, bool failed)
{
add_counter(&s_counters.authentication_attempts, 1U);
if (failed) {
add_counter(&s_counters.authentication_failures, 1U);
}
if (slot != NULL && slot->authentication_attempts < UINT8_MAX) {
++slot->authentication_attempts;
}
if (!failed || slot == NULL ||
slot->authentication_attempts < SSH_TRANSPORT_MAX_AUTH_ATTEMPTS) {
return true;
}
slot->close_requested = true;
if (slot->socket_fd >= 0) {
(void)shutdown(slot->socket_fd, SHUT_RDWR);
}
return false;
}
static int authenticate_password(ssh_slot_t *slot,
WS_UserAuthData *authentication)
{
clear_pending_principal(slot);
if (authentication->sf.password.hasNewPassword != 0U) {
(void)complete_authentication_attempt(slot, true);
return slot->close_requested ? WOLFSSH_USERAUTH_REJECTED
: WOLFSSH_USERAUTH_INVALID_AUTHTYPE;
}
user_principal_t principal;
bool authenticated = false;
esp_err_t error = user_database_authenticate_password(
authentication->username, authentication->usernameSz,
authentication->sf.password.password,
authentication->sf.password.passwordSz,
&principal, &authenticated);
if (error == ESP_OK && authenticated) {
(void)complete_authentication_attempt(slot, false);
slot->principal = principal;
slot->principal_valid = true;
slot->authenticated = true;
return WOLFSSH_USERAUTH_SUCCESS;
}
secure_wipe(&principal, sizeof(principal));
bool retry = complete_authentication_attempt(slot, true);
if (!retry) {
return WOLFSSH_USERAUTH_REJECTED;
}
return error == ESP_OK ? WOLFSSH_USERAUTH_INVALID_PASSWORD
: WOLFSSH_USERAUTH_FAILURE;
}
static int authenticate_public_key(ssh_slot_t *slot,
WS_UserAuthData *authentication)
{
WS_UserAuthData_PublicKey *public_key = &authentication->sf.publicKey;
clear_pending_principal(slot);
user_principal_t principal;
bool authorized = false;
esp_err_t error = ESP_OK;
if (public_key->isCert != 0U) {
memset(&principal, 0, sizeof(principal));
} else {
error = user_database_authorize_ssh_public_key(
authentication->username, authentication->usernameSz,
public_key->publicKeyType, public_key->publicKeyTypeSz,
public_key->publicKey, public_key->publicKeySz,
&principal, &authorized);
}
if (error != ESP_OK || !authorized) {
secure_wipe(&principal, sizeof(principal));
if (public_key->hasSignature == 0U) {
return error == ESP_OK ? WOLFSSH_USERAUTH_INVALID_PUBLICKEY
: WOLFSSH_USERAUTH_FAILURE;
}
bool retry = complete_authentication_attempt(slot, true);
if (!retry) {
return WOLFSSH_USERAUTH_REJECTED;
}
return error == ESP_OK ? WOLFSSH_USERAUTH_INVALID_PUBLICKEY
: WOLFSSH_USERAUTH_FAILURE;
}
if (public_key->hasSignature != 0U) {
slot->pending_principal = principal;
slot->pending_principal_valid = true;
}
secure_wipe(&principal, sizeof(principal));
return WOLFSSH_USERAUTH_SUCCESS;
} }
static int authenticate_user(byte authentication_type, static int authenticate_user(byte authentication_type,
@@ -231,39 +348,56 @@ static int authenticate_user(byte authentication_type,
void *context) void *context)
{ {
ssh_slot_t *slot = (ssh_slot_t *)context; ssh_slot_t *slot = (ssh_slot_t *)context;
add_counter(&s_counters.authentication_attempts, 1U);
if (slot != NULL && slot->authentication_attempts < UINT8_MAX) {
++slot->authentication_attempts;
}
if (slot == NULL || authentication == NULL || if (slot == NULL || authentication == NULL ||
authentication_type != WOLFSSH_USERAUTH_PASSWORD || authentication_type != authentication->type) {
authentication->type != WOLFSSH_USERAUTH_PASSWORD || clear_pending_principal(slot);
authentication->sf.password.hasNewPassword != 0U) {
add_counter(&s_counters.authentication_failures, 1U);
return WOLFSSH_USERAUTH_INVALID_AUTHTYPE; return WOLFSSH_USERAUTH_INVALID_AUTHTYPE;
} }
if (authentication_type == WOLFSSH_USERAUTH_PASSWORD) {
bool authenticated = false; return authenticate_password(slot, authentication);
esp_err_t error = web_security_authenticate_admin( }
authentication->username, authentication->usernameSz, if (authentication_type == WOLFSSH_USERAUTH_PUBLICKEY) {
authentication->sf.password.password, return authenticate_public_key(slot, authentication);
authentication->sf.password.passwordSz,
&authenticated);
if (error == ESP_OK && authenticated) {
slot->authenticated = true;
return WOLFSSH_USERAUTH_SUCCESS;
} }
add_counter(&s_counters.authentication_failures, 1U); clear_pending_principal(slot);
if (slot->authentication_attempts >= SSH_TRANSPORT_MAX_AUTH_ATTEMPTS) { return WOLFSSH_USERAUTH_INVALID_AUTHTYPE;
slot->close_requested = true; }
if (slot->socket_fd >= 0) {
(void)shutdown(slot->socket_fd, SHUT_RDWR); static int authentication_result(byte result, WS_UserAuthData *authentication,
} void *context)
return WOLFSSH_USERAUTH_REJECTED; {
ssh_slot_t *slot = (ssh_slot_t *)context;
if (slot == NULL || authentication == NULL ||
authentication->type != WOLFSSH_USERAUTH_PUBLICKEY ||
authentication->sf.publicKey.hasSignature == 0U) {
clear_pending_principal(slot);
return WS_ERROR;
} }
return error == ESP_OK ? WOLFSSH_USERAUTH_INVALID_PASSWORD
: WOLFSSH_USERAUTH_FAILURE; if (result != WOLFSSH_USERAUTH_SUCCESS) {
(void)complete_authentication_attempt(slot, true);
clear_pending_principal(slot);
return WS_SUCCESS;
}
bool current = false;
esp_err_t error = slot->pending_principal_valid
? user_database_principal_is_current(
&slot->pending_principal, &current)
: ESP_ERR_INVALID_STATE;
if (error != ESP_OK || !current) {
(void)complete_authentication_attempt(slot, true);
clear_pending_principal(slot);
return WS_ERROR;
}
(void)complete_authentication_attempt(slot, false);
slot->principal = slot->pending_principal;
slot->principal_valid = true;
slot->authenticated = true;
clear_pending_principal(slot);
return WS_SUCCESS;
} }
static int accept_shell(WOLFSSH_CHANNEL *channel, void *context) static int accept_shell(WOLFSSH_CHANNEL *channel, void *context)
@@ -382,6 +516,7 @@ static esp_err_t create_context(void)
wolfSSH_SetIORecv(context, bounded_ssh_receive); wolfSSH_SetIORecv(context, bounded_ssh_receive);
wolfSSH_SetUserAuth(context, authenticate_user); wolfSSH_SetUserAuth(context, authenticate_user);
wolfSSH_SetUserAuthTypes(context, allowed_auth_types); wolfSSH_SetUserAuthTypes(context, allowed_auth_types);
wolfSSH_SetUserAuthResult(context, authentication_result);
(void)wolfSSH_CTX_SetChannelReqShellCb(context, accept_shell); (void)wolfSSH_CTX_SetChannelReqShellCb(context, accept_shell);
(void)wolfSSH_CTX_SetChannelReqExecCb(context, reject_channel_request); (void)wolfSSH_CTX_SetChannelReqExecCb(context, reject_channel_request);
(void)wolfSSH_CTX_SetChannelReqSubsysCb(context, reject_channel_request); (void)wolfSSH_CTX_SetChannelReqSubsysCb(context, reject_channel_request);
@@ -640,11 +775,35 @@ static void accept_connections(void)
} }
wolfSSH_SetIOReadCtx(slot->ssh, slot); wolfSSH_SetIOReadCtx(slot->ssh, slot);
wolfSSH_SetUserAuthCtx(slot->ssh, slot); wolfSSH_SetUserAuthCtx(slot->ssh, slot);
wolfSSH_SetUserAuthResultCtx(slot->ssh, slot);
wolfSSH_SetChannelReqCtx(slot->ssh, slot); wolfSSH_SetChannelReqCtx(slot->ssh, slot);
publish_slot(slot, slot_index); publish_slot(slot, slot_index);
} }
} }
static bool slot_principal_is_current(const ssh_slot_t *slot)
{
bool current = false;
return slot->principal_valid &&
user_database_principal_is_current(&slot->principal, &current) == ESP_OK &&
current;
}
static void disconnect_failed_admission(ssh_slot_t *slot)
{
if (slot->broker_client_id == SESSION_BROKER_NO_CLIENT) {
return;
}
esp_err_t error = session_broker_disconnect(slot->broker_client_id);
if (error == ESP_OK || error == ESP_ERR_NOT_FOUND) {
slot->broker_client_id = SESSION_BROKER_NO_CLIENT;
slot->writer = false;
add_counter(&s_counters.disconnections, 1U);
} else {
add_counter(&s_counters.broker_failures, 1U);
}
}
static esp_err_t connect_broker(ssh_slot_t *slot, size_t slot_index) static esp_err_t connect_broker(ssh_slot_t *slot, size_t slot_index)
{ {
if (!serial_service_is_running()) { if (!serial_service_is_running()) {
@@ -657,6 +816,10 @@ static esp_err_t connect_broker(ssh_slot_t *slot, size_t slot_index)
} }
} }
if (!slot_principal_is_current(slot)) {
return ESP_ERR_INVALID_STATE;
}
char name[SESSION_BROKER_CLIENT_NAME_MAX + 1U]; char name[SESSION_BROKER_CLIENT_NAME_MAX + 1U];
int written = snprintf(name, sizeof(name), "ssh-%u-%" PRIu32, int written = snprintf(name, sizeof(name), "ssh-%u-%" PRIu32,
(unsigned int)slot_index, slot->generation); (unsigned int)slot_index, slot->generation);
@@ -670,19 +833,26 @@ static esp_err_t connect_broker(ssh_slot_t *slot, size_t slot_index)
return error; return error;
} }
add_counter(&s_counters.broker_connections, 1U); add_counter(&s_counters.broker_connections, 1U);
if (!slot_principal_is_current(slot)) {
disconnect_failed_admission(slot);
return ESP_ERR_INVALID_STATE;
}
add_counter(&s_counters.writer_requests, 1U); add_counter(&s_counters.writer_requests, 1U);
error = session_broker_request_writer(slot->broker_client_id); error = session_broker_request_writer(slot->broker_client_id);
if (error == ESP_OK) { if (error == ESP_OK) {
slot->writer = true; slot->writer = true;
add_counter(&s_counters.writer_grants, 1U); add_counter(&s_counters.writer_grants, 1U);
return ESP_OK; } else if (error == ESP_ERR_INVALID_STATE) {
}
if (error == ESP_ERR_INVALID_STATE) {
slot->writer = false; slot->writer = false;
add_counter(&s_counters.writer_denials, 1U); add_counter(&s_counters.writer_denials, 1U);
return ESP_OK; } else {
return error;
} }
return error; if (!slot_principal_is_current(slot)) {
disconnect_failed_admission(slot);
return ESP_ERR_INVALID_STATE;
}
return ESP_OK;
} }
static void process_handshake(ssh_slot_t *slot, size_t slot_index) static void process_handshake(ssh_slot_t *slot, size_t slot_index)
@@ -696,7 +866,20 @@ static void process_handshake(ssh_slot_t *slot, size_t slot_index)
slot->io_read_budget = SSH_TRANSPORT_WOLFSSH_READ_BUDGET; slot->io_read_budget = SSH_TRANSPORT_WOLFSSH_READ_BUDGET;
int result = wolfSSH_accept(slot->ssh); int result = wolfSSH_accept(slot->ssh);
if (result == WS_SUCCESS) { if (result == WS_SUCCESS) {
if (!slot->authenticated || !slot->shell_requested || bool principal_current = false;
esp_err_t principal_error = slot->principal_valid
? user_database_principal_is_current(
&slot->principal, &principal_current)
: ESP_ERR_INVALID_STATE;
if (!slot->authenticated || !slot->principal_valid) {
request_slot_close(slot, false);
return;
}
if (principal_error != ESP_OK || !principal_current) {
request_slot_close(slot, true);
return;
}
if (!slot->shell_requested ||
wolfSSH_GetSessionType(slot->ssh) != WOLFSSH_SESSION_SHELL) { wolfSSH_GetSessionType(slot->ssh) != WOLFSSH_SESSION_SHELL) {
add_counter(&s_counters.request_rejections, 1U); add_counter(&s_counters.request_rejections, 1U);
request_slot_close(slot, false); request_slot_close(slot, false);
@@ -709,6 +892,11 @@ static void process_handshake(ssh_slot_t *slot, size_t slot_index)
request_slot_close(slot, false); request_slot_close(slot, false);
return; return;
} }
if (!slot_principal_is_current(slot)) {
disconnect_failed_admission(slot);
request_slot_close(slot, true);
return;
}
slot->state = SSH_TRANSPORT_SESSION_ACTIVE; slot->state = SSH_TRANSPORT_SESSION_ACTIVE;
slot->last_reconcile_us = esp_timer_get_time(); slot->last_reconcile_us = esp_timer_get_time();
add_counter(&s_counters.handshake_successes, 1U); add_counter(&s_counters.handshake_successes, 1U);
@@ -728,6 +916,16 @@ static bool reconcile_writer(ssh_slot_t *slot)
} }
slot->last_reconcile_us = now; slot->last_reconcile_us = now;
bool principal_current = false;
esp_err_t principal_error = slot->principal_valid
? user_database_principal_is_current(
&slot->principal, &principal_current)
: ESP_ERR_INVALID_STATE;
if (principal_error != ESP_OK || !principal_current) {
request_slot_close(slot, true);
return true;
}
session_broker_client_snapshot_t snapshot; session_broker_client_snapshot_t snapshot;
esp_err_t error = session_broker_get_client_snapshot( esp_err_t error = session_broker_get_client_snapshot(
slot->broker_client_id, &snapshot); slot->broker_client_id, &snapshot);
@@ -1201,6 +1399,36 @@ esp_err_t ssh_transport_disconnect(uint32_t session_id)
return ESP_ERR_NOT_FOUND; return ESP_ERR_NOT_FOUND;
} }
esp_err_t ssh_transport_revoke_user(const uint8_t *username,
size_t username_length)
{
if (!user_database_username_valid(username, username_length)) {
return ESP_ERR_INVALID_ARG;
}
bool found = false;
taskENTER_CRITICAL(&s_lock);
if (!s_initialized) {
taskEXIT_CRITICAL(&s_lock);
return ESP_ERR_INVALID_STATE;
}
for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) {
const ssh_transport_session_snapshot_t *session =
&s_session_snapshots[index];
if (session->active && session->principal_valid &&
strlen(session->username) == username_length &&
memcmp(session->username, username, username_length) == 0) {
s_external_close_id[index] = session->session_id;
found = true;
}
}
taskEXIT_CRITICAL(&s_lock);
if (found) {
notify_task();
}
return ESP_OK;
}
esp_err_t ssh_transport_revoke_sessions(void) esp_err_t ssh_transport_revoke_sessions(void)
{ {
taskENTER_CRITICAL(&s_lock); taskENTER_CRITICAL(&s_lock);
+8 -1
View File
@@ -9,6 +9,7 @@
#include "esp_err.h" #include "esp_err.h"
#include "session_broker.h" #include "session_broker.h"
#include "user_database.h"
#ifdef __cplusplus #ifdef __cplusplus
extern "C" { extern "C" {
@@ -56,6 +57,7 @@ typedef struct {
typedef struct { typedef struct {
bool active; bool active;
bool authenticated; bool authenticated;
bool principal_valid;
bool writer; bool writer;
bool close_requested; bool close_requested;
bool rx_pending; bool rx_pending;
@@ -65,6 +67,9 @@ typedef struct {
int socket_fd; int socket_fd;
session_broker_client_id_t broker_client_id; session_broker_client_id_t broker_client_id;
ssh_transport_session_state_t state; ssh_transport_session_state_t state;
user_role_t user_role;
user_auth_method_t auth_method;
char username[USER_DATABASE_USERNAME_CAPACITY + 1U];
char peer[48]; char peer[48];
} ssh_transport_session_snapshot_t; } ssh_transport_session_snapshot_t;
@@ -93,8 +98,10 @@ esp_err_t ssh_transport_replace_host_key(bool reset);
esp_err_t ssh_transport_get_snapshot(ssh_transport_snapshot_t *snapshot); esp_err_t ssh_transport_get_snapshot(ssh_transport_snapshot_t *snapshot);
esp_err_t ssh_transport_clear_counters(void); esp_err_t ssh_transport_clear_counters(void);
/* Close one transport session or all authenticated/handshaking sessions. */ /* Close one session, one account's sessions, or every transport session. */
esp_err_t ssh_transport_disconnect(uint32_t session_id); esp_err_t ssh_transport_disconnect(uint32_t session_id);
esp_err_t ssh_transport_revoke_user(const uint8_t *username,
size_t username_length);
esp_err_t ssh_transport_revoke_sessions(void); esp_err_t ssh_transport_revoke_sessions(void);
#ifdef __cplusplus #ifdef __cplusplus
+29 -3
View File
@@ -11,8 +11,10 @@
#include "esp_console.h" #include "esp_console.h"
#include "mbedtls/base64.h" #include "mbedtls/base64.h"
#include "secure_random.h" #include "secure_random.h"
#include "ssh_transport.h"
#include "user_database.h" #include "user_database.h"
#include "web_security.h" #include "web_security.h"
#include "web_serial_transport.h"
#define USER_CONSOLE_KEY_LINE_CAPACITY 256U #define USER_CONSOLE_KEY_LINE_CAPACITY 256U
@@ -32,6 +34,22 @@ static void print_usage(void)
printf(" user key clear <username> --force\n"); printf(" user key clear <username> --force\n");
} }
static void revoke_user_network_sessions(const char *username)
{
size_t username_length = strlen(username);
esp_err_t web_error = web_serial_transport_revoke_user(
(const uint8_t *)username, username_length);
esp_err_t ssh_error = ssh_transport_revoke_user(
(const uint8_t *)username, username_length);
if (web_error != ESP_OK && web_error != ESP_ERR_INVALID_STATE) {
printf("Warning: WebSocket revocation failed: %s\n",
esp_err_to_name(web_error));
}
if (ssh_error != ESP_OK && ssh_error != ESP_ERR_INVALID_STATE) {
printf("Warning: SSH revocation failed: %s\n", esp_err_to_name(ssh_error));
}
}
static void print_fingerprint(const uint8_t fingerprint[USER_DATABASE_SHA256_LENGTH]) static void print_fingerprint(const uint8_t fingerprint[USER_DATABASE_SHA256_LENGTH])
{ {
uint8_t encoded[48] = {0}; uint8_t encoded[48] = {0};
@@ -191,7 +209,8 @@ static int bootstrap(bool generated)
printf("Could not bootstrap administrator: %s\n", esp_err_to_name(error)); printf("Could not bootstrap administrator: %s\n", esp_err_to_name(error));
return 1; return 1;
} }
printf("Administrator account bootstrapped. Authentication integration follows in Phase 8B.\n"); revoke_user_network_sessions("admin");
printf("Administrator account bootstrapped. Role-aware HTTPS and SSH authentication is active.\n");
return 0; return 0;
} }
@@ -225,6 +244,7 @@ static int add_user(const char *username, const char *role_text, bool generated)
printf("Could not add user: %s\n", esp_err_to_name(error)); printf("Could not add user: %s\n", esp_err_to_name(error));
return 1; return 1;
} }
revoke_user_network_sessions(username);
printf("User '%s' added with role %s.\n", username, user_role_to_string(role)); printf("User '%s' added with role %s.\n", username, user_role_to_string(role));
return 0; return 0;
} }
@@ -254,7 +274,8 @@ static int change_password(const char *username, bool generated)
printf("Could not change password: %s\n", esp_err_to_name(error)); printf("Could not change password: %s\n", esp_err_to_name(error));
return 1; return 1;
} }
printf("Password changed; affected network sessions will be revoked in Phase 8B.\n"); revoke_user_network_sessions(username);
printf("Password changed; affected network sessions are now stale and will be revoked.\n");
return 0; return 0;
} }
@@ -338,7 +359,8 @@ static int add_key(const char *username)
printf("Could not add SSH key: %s\n", esp_err_to_name(error)); printf("Could not add SSH key: %s\n", esp_err_to_name(error));
return 1; return 1;
} }
printf("SSH public key added at index %u. Key login is enabled in Phase 8B.\n", revoke_user_network_sessions(username);
printf("SSH public key added at index %u. Public-key login is active.\n",
(unsigned int)key_index); (unsigned int)key_index);
return 0; return 0;
} }
@@ -380,6 +402,7 @@ static int command_user(int argc, char **argv)
esp_err_to_name(error)); esp_err_to_name(error));
return 1; return 1;
} }
revoke_user_network_sessions(argv[2]);
printf("User '%s' deleted.\n", argv[2]); printf("User '%s' deleted.\n", argv[2]);
return 0; return 0;
} }
@@ -398,6 +421,7 @@ static int command_user(int argc, char **argv)
esp_err_to_name(error)); esp_err_to_name(error));
return 1; return 1;
} }
revoke_user_network_sessions(argv[2]);
printf("User '%s' role changed to %s.\n", argv[2], user_role_to_string(role)); printf("User '%s' role changed to %s.\n", argv[2], user_role_to_string(role));
return 0; return 0;
} }
@@ -426,6 +450,7 @@ static int command_user(int argc, char **argv)
printf("Could not delete SSH key: %s\n", esp_err_to_name(error)); printf("Could not delete SSH key: %s\n", esp_err_to_name(error));
return 1; return 1;
} }
revoke_user_network_sessions(argv[3]);
printf("SSH key %u deleted for '%s'.\n", (unsigned int)index, argv[3]); printf("SSH key %u deleted for '%s'.\n", (unsigned int)index, argv[3]);
return 0; return 0;
} }
@@ -437,6 +462,7 @@ static int command_user(int argc, char **argv)
printf("Could not clear SSH keys: %s\n", esp_err_to_name(error)); printf("Could not clear SSH keys: %s\n", esp_err_to_name(error));
return 1; return 1;
} }
revoke_user_network_sessions(argv[3]);
printf("SSH keys cleared for '%s'.\n", argv[3]); printf("SSH keys cleared for '%s'.\n", argv[3]);
return 0; return 0;
} }
+2 -2
View File
@@ -94,8 +94,8 @@ esp_err_t user_database_init(const user_database_legacy_credentials_t *legacy,
user_database_load_result_t *load_result); user_database_load_result_t *load_result);
/* /*
* Before the first administrator is established, keep the migrated account in * Before the first administrator is established, keep the migrated account in
* sync with Phase 8A's legacy network credential. Once bootstrapped, the two * sync with the legacy recovery credential. Once bootstrapped, that credential
* credentials deliberately remain independent until the Phase 8B cutover. * remains independent and no longer authenticates Phase 8B network services.
*/ */
esp_err_t user_database_sync_legacy_credentials( esp_err_t user_database_sync_legacy_credentials(
const user_database_legacy_credentials_t *legacy, bool *synchronized); const user_database_legacy_credentials_t *legacy, bool *synchronized);
+30 -36
View File
@@ -1,5 +1,5 @@
/* SPDX-License-Identifier: GPL-3.0-only */ /* SPDX-License-Identifier: GPL-3.0-only */
/* UART0 HTTPS lifecycle, shared credentials, certificate, and recovery commands. */ /* UART0 HTTPS lifecycle, legacy recovery credential, and certificate commands. */
#include "web_console.h" #include "web_console.h"
@@ -43,22 +43,20 @@ static int show_status(void)
return 1; return 1;
} }
char username[WEB_SECURITY_USERNAME_CAPACITY + 1U] = {0}; user_database_snapshot_t users;
size_t username_length = 0U; esp_err_t users_error = user_database_get_snapshot(&users);
esp_err_t security_error = web_security_copy_username(
username, sizeof(username), &username_length);
printf("HTTPS: initialized=%s running=%s transitioning=%s port=%u last-error=%s\n", printf("HTTPS: initialized=%s running=%s transitioning=%s port=%u last-error=%s\n",
snapshot.initialized ? "yes" : "no", snapshot.initialized ? "yes" : "no",
snapshot.running ? "yes" : "no", snapshot.running ? "yes" : "no",
snapshot.transitioning ? "yes" : "no", snapshot.transitioning ? "yes" : "no",
(unsigned int)snapshot.port, (unsigned int)snapshot.port,
esp_err_to_name(snapshot.last_error)); esp_err_to_name(snapshot.last_error));
if (security_error == ESP_OK) { if (users_error == ESP_OK) {
printf("Authentication: HTTP Basic over TLS, username=%.*s, shared with SSH\n", printf("Authentication: HTTP Basic over TLS via user database, users=%u admins=%u\n",
(int)username_length, username); (unsigned int)users.user_count, (unsigned int)users.admin_count);
} else { } else {
printf("Authentication material unavailable: %s; use 'web reset --force' to replace it.\n", printf("Authentication database unavailable: %s; use 'user recover --force'.\n",
esp_err_to_name(security_error)); esp_err_to_name(users_error));
} }
printf("Endpoints: GET /, GET /api/status, POST /api/ws-ticket, WSS /ws/serial\n"); printf("Endpoints: GET /, GET /api/status, POST /api/ws-ticket, WSS /ws/serial\n");
@@ -81,11 +79,20 @@ static int show_status(void)
if (!session->active) { if (!session->active) {
continue; continue;
} }
printf(" slot=%u fd=%d generation=%" PRIu32 " broker=%" PRIu32 printf(" slot=%u fd=%d generation=%" PRIu32 " account=%s user-role=%s"
" role=%s tx-pending=%s closing=%s\n", " method=%s broker=%" PRIu32
" broker-role=%s tx-pending=%s closing=%s\n",
(unsigned int)index, (unsigned int)index,
session->socket_fd, session->socket_fd,
session->generation, session->generation,
session->principal_valid ? session->username : "-",
session->principal_valid
? user_role_to_string(session->user_role)
: "-",
session->principal_valid &&
session->auth_method == USER_AUTH_METHOD_PASSWORD
? "password"
: "unknown",
session->broker_client_id, session->broker_client_id,
session->writer ? "writer" : "observer", session->writer ? "writer" : "observer",
session->tx_pending ? "yes" : "no", session->tx_pending ? "yes" : "no",
@@ -169,7 +176,8 @@ static int show_credentials(void)
credentials.username); credentials.username);
printf("Password: %.*s\n", (int)credentials.password_length, printf("Password: %.*s\n", (int)credentials.password_length,
credentials.password); credentials.password);
printf("These credentials protect HTTPS and SSH. Keep them private.\n"); printf("Phase 8B uses the user database for HTTPS and SSH authentication.\n");
printf("This legacy credential is retained only for migration and physical recovery.\n");
secure_wipe(&credentials, sizeof(credentials)); secure_wipe(&credentials, sizeof(credentials));
return 0; return 0;
} }
@@ -241,6 +249,12 @@ static void synchronize_migrated_user(
return; return;
} }
if (synchronized) { if (synchronized) {
(void)web_serial_transport_revoke_user(
(const uint8_t *)credentials->username,
credentials->username_length);
(void)ssh_transport_revoke_user(
(const uint8_t *)credentials->username,
credentials->username_length);
printf("The pre-bootstrap migrated user credential was synchronized.\n"); printf("The pre-bootstrap migrated user credential was synchronized.\n");
return; return;
} }
@@ -248,7 +262,7 @@ static void synchronize_migrated_user(
user_database_snapshot_t snapshot; user_database_snapshot_t snapshot;
if (user_database_get_snapshot(&snapshot) == ESP_OK && if (user_database_get_snapshot(&snapshot) == ESP_OK &&
snapshot.admin_bootstrapped) { snapshot.admin_bootstrapped) {
printf("Phase 8A note: this legacy HTTPS/SSH credential is separate from bootstrapped user passwords until Phase 8B.\n"); printf("This legacy recovery credential is separate from role-based user passwords.\n");
} else { } else {
printf("Warning: no matching pre-bootstrap migrated user was synchronized; establish an administrator with 'user bootstrap'.\n"); printf("Warning: no matching pre-bootstrap migrated user was synchronized; establish an administrator with 'user bootstrap'.\n");
} }
@@ -264,17 +278,7 @@ static int rotate_credentials(void)
} }
synchronize_migrated_user(&credentials); synchronize_migrated_user(&credentials);
esp_err_t web_revoke_error = web_serial_transport_revoke_sessions(); printf("Legacy migration/recovery credential rotated and persisted.\n");
esp_err_t ssh_revoke_error = ssh_transport_revoke_sessions();
printf("Administrative credentials rotated and persisted. Existing HTTPS and SSH credentials are now invalid.\n");
if (web_revoke_error != ESP_OK && web_revoke_error != ESP_ERR_INVALID_STATE) {
printf("Warning: existing WebSocket sessions could not be revoked: %s\n",
esp_err_to_name(web_revoke_error));
}
if (ssh_revoke_error != ESP_OK && ssh_revoke_error != ESP_ERR_INVALID_STATE) {
printf("Warning: existing SSH sessions could not be revoked: %s\n",
esp_err_to_name(ssh_revoke_error));
}
printf("Username: %.*s\nPassword: %.*s\n", printf("Username: %.*s\nPassword: %.*s\n",
(int)credentials.username_length, credentials.username, (int)credentials.username_length, credentials.username,
(int)credentials.password_length, credentials.password); (int)credentials.password_length, credentials.password);
@@ -311,17 +315,7 @@ static int reset_material(void)
} }
synchronize_migrated_user(&credentials); synchronize_migrated_user(&credentials);
esp_err_t web_revoke_error = web_serial_transport_revoke_sessions(); printf("Legacy recovery credential, HTTPS certificate, and HTTPS private key replaced and persisted.\n");
esp_err_t ssh_revoke_error = ssh_transport_revoke_sessions();
printf("Administrative credentials, HTTPS certificate, and HTTPS private key replaced and persisted.\n");
if (web_revoke_error != ESP_OK && web_revoke_error != ESP_ERR_INVALID_STATE) {
printf("Warning: existing WebSocket sessions could not be revoked: %s\n",
esp_err_to_name(web_revoke_error));
}
if (ssh_revoke_error != ESP_OK && ssh_revoke_error != ESP_ERR_INVALID_STATE) {
printf("Warning: existing SSH sessions could not be revoked: %s\n",
esp_err_to_name(ssh_revoke_error));
}
printf("Username: %.*s\nPassword: %.*s\n", printf("Username: %.*s\nPassword: %.*s\n",
(int)credentials.username_length, credentials.username, (int)credentials.username_length, credentials.username,
(int)credentials.password_length, credentials.password); (int)credentials.password_length, credentials.password);
+1 -112
View File
@@ -1,5 +1,5 @@
/* SPDX-License-Identifier: GPL-3.0-only */ /* SPDX-License-Identifier: GPL-3.0-only */
/* Canonical NVS storage for HTTPS identity and shared admin credentials. */ /* Canonical NVS storage for HTTPS identity and legacy recovery credentials. */
#include "web_security.h" #include "web_security.h"
@@ -699,117 +699,6 @@ esp_err_t web_security_copy_tls_material(
return error; return error;
} }
esp_err_t web_security_copy_username(char *output, size_t capacity,
size_t *output_length)
{
if (s_security_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
esp_err_t error = ESP_ERR_INVALID_STATE;
if (s_material_ready) {
if (output_length == NULL || (output == NULL && capacity != 0U)) {
error = ESP_ERR_INVALID_ARG;
} else {
*output_length = s_material.username_length;
if (output == NULL) {
error = capacity == 0U ? ESP_OK : ESP_ERR_INVALID_ARG;
} else if (capacity <= s_material.username_length) {
error = ESP_ERR_INVALID_SIZE;
} else {
memcpy(output, s_material.username, s_material.username_length);
output[s_material.username_length] = '\0';
error = ESP_OK;
}
}
}
xSemaphoreGive(s_security_mutex);
return error;
}
static esp_err_t credential_digest(const uint8_t *username, size_t username_length,
const uint8_t *password, size_t password_length,
uint8_t digest[WEB_SECURITY_SHA256_LENGTH])
{
uint8_t canonical[4U + WEB_SECURITY_USERNAME_CAPACITY +
WEB_SECURITY_PASSWORD_CAPACITY] = {0};
size_t offset = 0U;
canonical[offset++] = (uint8_t)(username_length >> 8U);
canonical[offset++] = (uint8_t)username_length;
if (username_length > 0U) {
memcpy(canonical + offset, username, username_length);
offset += username_length;
}
canonical[offset++] = (uint8_t)(password_length >> 8U);
canonical[offset++] = (uint8_t)password_length;
if (password_length > 0U) {
memcpy(canonical + offset, password, password_length);
offset += password_length;
}
int result = mbedtls_sha256(canonical, offset, digest, 0);
secure_wipe(canonical, sizeof(canonical));
return result == 0 ? ESP_OK : ESP_FAIL;
}
esp_err_t web_security_authenticate_admin(const uint8_t *username,
size_t username_length,
const uint8_t *password,
size_t password_length,
bool *authenticated)
{
if (authenticated == NULL ||
(username == NULL && username_length != 0U) ||
(password == NULL && password_length != 0U)) {
return ESP_ERR_INVALID_ARG;
}
*authenticated = false;
if (s_security_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
if (username_length > WEB_SECURITY_USERNAME_CAPACITY ||
password_length > WEB_SECURITY_PASSWORD_CAPACITY) {
return ESP_OK;
}
uint8_t supplied_digest[WEB_SECURITY_SHA256_LENGTH] = {0};
uint8_t expected_digest[WEB_SECURITY_SHA256_LENGTH] = {0};
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
esp_err_t error = ESP_ERR_INVALID_STATE;
if (s_material_ready) {
error = credential_digest(username, username_length,
password, password_length,
supplied_digest);
if (error == ESP_OK) {
error = credential_digest(s_material.username,
s_material.username_length,
s_material.password,
s_material.password_length,
expected_digest);
}
if (error == ESP_OK) {
*authenticated = constant_time_equal(supplied_digest,
expected_digest,
sizeof(expected_digest));
}
}
xSemaphoreGive(s_security_mutex);
secure_wipe(supplied_digest, sizeof(supplied_digest));
secure_wipe(expected_digest, sizeof(expected_digest));
return error;
}
esp_err_t web_security_authenticate_basic(const uint8_t *username,
size_t username_length,
const uint8_t *password,
size_t password_length,
bool *authenticated)
{
return web_security_authenticate_admin(username, username_length,
password, password_length,
authenticated);
}
static void copy_credentials_locked(web_security_credentials_t *credentials, static void copy_credentials_locked(web_security_credentials_t *credentials,
const web_security_blob_t *blob) const web_security_blob_t *blob)
+1 -17
View File
@@ -1,5 +1,5 @@
/* SPDX-License-Identifier: GPL-3.0-only */ /* SPDX-License-Identifier: GPL-3.0-only */
/* Persistent HTTPS identity and shared network-administration credentials. */ /* Persistent HTTPS identity and legacy migration/recovery credentials. */
#pragma once #pragma once
@@ -72,22 +72,6 @@ esp_err_t web_security_copy_tls_material(
size_t *certificate_length, size_t *certificate_length,
uint8_t *private_key, size_t private_key_capacity, uint8_t *private_key, size_t private_key_capacity,
size_t *private_key_length); size_t *private_key_length);
esp_err_t web_security_copy_username(char *output, size_t capacity,
size_t *output_length);
/* Protocol-neutral authentication for the shared HTTPS and SSH administrator. */
esp_err_t web_security_authenticate_admin(const uint8_t *username,
size_t username_length,
const uint8_t *password,
size_t password_length,
bool *authenticated);
/* Compatibility name for decoded HTTP Basic components. */
esp_err_t web_security_authenticate_basic(const uint8_t *username,
size_t username_length,
const uint8_t *password,
size_t password_length,
bool *authenticated);
/* Explicit secret-bearing API intended for a physically attached UART CLI. */ /* Explicit secret-bearing API intended for a physically attached UART CLI. */
esp_err_t web_security_show_credentials(web_security_credentials_t *credentials); esp_err_t web_security_show_credentials(web_security_credentials_t *credentials);
+241 -68
View File
@@ -14,7 +14,6 @@
#include "sdkconfig.h" #include "sdkconfig.h"
#include "secure_random.h" #include "secure_random.h"
#include "serial_service.h" #include "serial_service.h"
#include "web_security.h"
#if !defined(CONFIG_HTTPD_WS_SUPPORT) || !CONFIG_HTTPD_WS_SUPPORT #if !defined(CONFIG_HTTPD_WS_SUPPORT) || !CONFIG_HTTPD_WS_SUPPORT
#error "web_serial_transport requires CONFIG_HTTPD_WS_SUPPORT" #error "web_serial_transport requires CONFIG_HTTPD_WS_SUPPORT"
@@ -30,6 +29,7 @@
#define WEB_SERIAL_TASK_PRIORITY 4U #define WEB_SERIAL_TASK_PRIORITY 4U
#define WEB_SERIAL_ACTIVE_BURST_LOOPS 8U #define WEB_SERIAL_ACTIVE_BURST_LOOPS 8U
#define WEB_SERIAL_POLL_MS 5U #define WEB_SERIAL_POLL_MS 5U
#define WEB_SERIAL_CURRENTNESS_INTERVAL_US 250000LL
#define WEB_SERIAL_DETACH_TIMEOUT_US 1000000LL #define WEB_SERIAL_DETACH_TIMEOUT_US 1000000LL
_Static_assert(WEB_SERIAL_TRANSPORT_TICKET_LENGTH == _Static_assert(WEB_SERIAL_TRANSPORT_TICKET_LENGTH ==
@@ -48,7 +48,7 @@ typedef enum {
typedef struct { typedef struct {
uint8_t digest[WEB_SERIAL_SHA256_BYTES]; uint8_t digest[WEB_SERIAL_SHA256_BYTES];
int64_t expires_at_us; int64_t expires_at_us;
uint32_t material_generation; user_principal_t principal;
bool active; bool active;
} web_serial_ticket_t; } web_serial_ticket_t;
@@ -67,6 +67,8 @@ typedef struct web_serial_slot {
int socket_fd; int socket_fd;
uint32_t generation; uint32_t generation;
session_broker_client_id_t broker_client_id; session_broker_client_id_t broker_client_id;
user_principal_t principal;
int64_t next_currentness_check_us;
bool writer; bool writer;
bool hello_pending; bool hello_pending;
bool work_pending; bool work_pending;
@@ -142,8 +144,8 @@ static bool slot_pointer_valid(const web_serial_slot_t *slot)
static void clear_ticket_locked(web_serial_ticket_t *ticket) static void clear_ticket_locked(web_serial_ticket_t *ticket)
{ {
secure_wipe(ticket->digest, sizeof(ticket->digest)); secure_wipe(ticket->digest, sizeof(ticket->digest));
secure_wipe(&ticket->principal, sizeof(ticket->principal));
ticket->expires_at_us = 0; ticket->expires_at_us = 0;
ticket->material_generation = 0U;
ticket->active = false; ticket->active = false;
} }
@@ -154,20 +156,13 @@ static void clear_all_tickets_locked(void)
} }
} }
static void purge_tickets_locked(int64_t now_us, uint32_t material_generation, static void purge_tickets_locked(int64_t now_us)
bool check_generation)
{ {
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_TICKETS; ++index) { for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_TICKETS; ++index) {
web_serial_ticket_t *ticket = &s_tickets[index]; web_serial_ticket_t *ticket = &s_tickets[index];
if (!ticket->active) { if (ticket->active && ticket->expires_at_us <= now_us) {
continue;
}
if (ticket->expires_at_us <= now_us) {
clear_ticket_locked(ticket); clear_ticket_locked(ticket);
++s_counters.tickets_expired; ++s_counters.tickets_expired;
} else if (check_generation &&
ticket->material_generation != material_generation) {
clear_ticket_locked(ticket);
} }
} }
} }
@@ -209,16 +204,6 @@ static bool base64url_character(char value)
(value >= '0' && value <= '9') || value == '-' || value == '_'; (value >= '0' && value <= '9') || value == '-' || value == '_';
} }
static esp_err_t current_material_generation(uint32_t *generation)
{
web_security_certificate_metadata_t metadata;
esp_err_t result = web_security_get_certificate_metadata(&metadata);
if (result == ESP_OK) {
*generation = metadata.material_generation;
}
secure_wipe(&metadata, sizeof(metadata));
return result;
}
static esp_err_t sha256_ticket(const char *ticket, static esp_err_t sha256_ticket(const char *ticket,
uint8_t digest[WEB_SERIAL_SHA256_BYTES]) uint8_t digest[WEB_SERIAL_SHA256_BYTES])
@@ -295,16 +280,16 @@ static esp_err_t validate_origin(httpd_req_t *request)
return matches ? ESP_OK : ESP_ERR_INVALID_ARG; return matches ? ESP_OK : ESP_ERR_INVALID_ARG;
} }
static esp_err_t consume_ticket(const char *ticket, bool *consumed) static esp_err_t consume_ticket(const char *ticket,
user_principal_t *principal, bool *consumed)
{ {
uint8_t digest[WEB_SERIAL_SHA256_BYTES] = {0}; uint8_t digest[WEB_SERIAL_SHA256_BYTES] = {0};
uint32_t generation = 0U; user_principal_t candidate = {0};
bool ticket_found = false;
*consumed = false; *consumed = false;
memset(principal, 0, sizeof(*principal));
esp_err_t result = current_material_generation(&generation); esp_err_t result = sha256_ticket(ticket, digest);
if (result == ESP_OK) {
result = sha256_ticket(ticket, digest);
}
if (result != ESP_OK) { if (result != ESP_OK) {
secure_wipe(digest, sizeof(digest)); secure_wipe(digest, sizeof(digest));
add_counter(&s_counters.tickets_rejected, 1U); add_counter(&s_counters.tickets_rejected, 1U);
@@ -326,36 +311,45 @@ static esp_err_t consume_ticket(const char *ticket, bool *consumed)
} }
} }
purge_tickets_locked(now_us, generation, true); purge_tickets_locked(now_us);
if (matching_count == 1U && if (matching_count == 1U &&
matching_index < WEB_SERIAL_TRANSPORT_MAX_TICKETS) { matching_index < WEB_SERIAL_TRANSPORT_MAX_TICKETS) {
web_serial_ticket_t *entry = &s_tickets[matching_index]; web_serial_ticket_t *entry = &s_tickets[matching_index];
if (entry->active && entry->expires_at_us > now_us && if (entry->active && entry->expires_at_us > now_us) {
entry->material_generation == generation) { candidate = entry->principal;
clear_ticket_locked(entry); clear_ticket_locked(entry);
++s_counters.tickets_consumed; ticket_found = true;
*consumed = true;
} }
} }
if (!*consumed) { if (!ticket_found && matching_count > 1U) {
/* A defensive duplicate can never become a repeatedly usable ticket. */ /* A defensive duplicate can never become a repeatedly usable ticket. */
if (matching_count > 1U) { for (size_t index = 0U;
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_TICKETS; ++index) {
index < WEB_SERIAL_TRANSPORT_MAX_TICKETS; ++index) { web_serial_ticket_t *entry = &s_tickets[index];
web_serial_ticket_t *entry = &s_tickets[index]; if (entry->active && constant_time_equal(
if (entry->active && constant_time_equal( entry->digest, digest, sizeof(digest))) {
entry->digest, digest, clear_ticket_locked(entry);
sizeof(digest))) {
clear_ticket_locked(entry);
}
} }
} }
++s_counters.tickets_rejected;
} }
taskEXIT_CRITICAL(&s_lock); taskEXIT_CRITICAL(&s_lock);
if (ticket_found) {
bool current = false;
result = user_database_principal_is_current(&candidate, &current);
if (result == ESP_OK && current) {
*principal = candidate;
*consumed = true;
add_counter(&s_counters.tickets_consumed, 1U);
}
}
if (!*consumed) {
add_counter(&s_counters.tickets_rejected, 1U);
}
secure_wipe(&candidate, sizeof(candidate));
secure_wipe(digest, sizeof(digest)); secure_wipe(digest, sizeof(digest));
return ESP_OK; return result;
} }
static web_serial_slot_t *reserve_slot(httpd_handle_t server, int socket_fd, static web_serial_slot_t *reserve_slot(httpd_handle_t server, int socket_fd,
@@ -376,6 +370,8 @@ static web_serial_slot_t *reserve_slot(httpd_handle_t server, int socket_fd,
slot->server = server; slot->server = server;
slot->socket_fd = socket_fd; slot->socket_fd = socket_fd;
slot->broker_client_id = SESSION_BROKER_NO_CLIENT; slot->broker_client_id = SESSION_BROKER_NO_CLIENT;
secure_wipe(&slot->principal, sizeof(slot->principal));
slot->next_currentness_check_us = 0;
slot->writer = false; slot->writer = false;
slot->hello_pending = false; slot->hello_pending = false;
slot->work_pending = false; slot->work_pending = false;
@@ -399,6 +395,8 @@ static void make_slot_free_locked(web_serial_slot_t *slot)
slot->server = NULL; slot->server = NULL;
slot->socket_fd = -1; slot->socket_fd = -1;
slot->broker_client_id = SESSION_BROKER_NO_CLIENT; slot->broker_client_id = SESSION_BROKER_NO_CLIENT;
secure_wipe(&slot->principal, sizeof(slot->principal));
slot->next_currentness_check_us = 0;
slot->writer = false; slot->writer = false;
slot->hello_pending = false; slot->hello_pending = false;
slot->work_pending = false; slot->work_pending = false;
@@ -481,6 +479,8 @@ static void web_serial_session_free(void *context)
taskENTER_CRITICAL(&s_lock); taskENTER_CRITICAL(&s_lock);
if (slot->state == WEB_SERIAL_SLOT_ACTIVE) { if (slot->state == WEB_SERIAL_SLOT_ACTIVE) {
slot->state = WEB_SERIAL_SLOT_CLOSING; slot->state = WEB_SERIAL_SLOT_CLOSING;
secure_wipe(&slot->principal, sizeof(slot->principal));
slot->next_currentness_check_us = 0;
slot->writer = false; slot->writer = false;
slot->hello_pending = false; slot->hello_pending = false;
slot->close_requested = false; slot->close_requested = false;
@@ -517,6 +517,7 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
char ticket[WEB_SERIAL_TRANSPORT_TICKET_CAPACITY] = {0}; char ticket[WEB_SERIAL_TRANSPORT_TICKET_CAPACITY] = {0};
uint32_t slot_generation = 0U; uint32_t slot_generation = 0U;
web_serial_slot_t *slot = NULL; web_serial_slot_t *slot = NULL;
user_principal_t principal = {0};
bool consumed = false; bool consumed = false;
esp_err_t result; esp_err_t result;
@@ -534,12 +535,25 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
goto cleanup; goto cleanup;
} }
result = consume_ticket(ticket, &consumed); result = consume_ticket(ticket, &principal, &consumed);
if (result != ESP_OK || !consumed) { if (result != ESP_OK || !consumed) {
release_reserved_slot(slot, slot_generation); release_reserved_slot(slot, slot_generation);
result = ESP_FAIL; result = ESP_FAIL;
goto cleanup; goto cleanup;
} }
taskENTER_CRITICAL(&s_lock);
bool principal_staged = slot->state == WEB_SERIAL_SLOT_RESERVED &&
slot->generation == slot_generation &&
!slot->close_requested;
if (principal_staged) {
slot->principal = principal;
}
taskEXIT_CRITICAL(&s_lock);
if (!principal_staged) {
release_reserved_slot(slot, slot_generation);
result = ESP_FAIL;
goto cleanup;
}
if (!serial_service_is_running()) { if (!serial_service_is_running()) {
result = serial_service_start(); result = serial_service_start();
@@ -556,6 +570,14 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
} }
} }
bool principal_current = false;
result = user_database_principal_is_current(&principal, &principal_current);
if (result != ESP_OK || !principal_current) {
release_reserved_slot(slot, slot_generation);
result = ESP_FAIL;
goto cleanup;
}
size_t slot_index = (size_t)(slot - s_slots); size_t slot_index = (size_t)(slot - s_slots);
char client_name[SESSION_BROKER_CLIENT_NAME_MAX + 1U]; char client_name[SESSION_BROKER_CLIENT_NAME_MAX + 1U];
int written = snprintf(client_name, sizeof(client_name), int written = snprintf(client_name, sizeof(client_name),
@@ -579,6 +601,14 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
goto cleanup; goto cleanup;
} }
principal_current = false;
result = user_database_principal_is_current(&principal, &principal_current);
if (result != ESP_OK || !principal_current) {
close_unpublished_broker_session(slot, slot_generation, client_id);
result = ESP_FAIL;
goto cleanup;
}
add_counter(&s_counters.writer_requests, 1U); add_counter(&s_counters.writer_requests, 1U);
result = session_broker_request_writer(client_id); result = session_broker_request_writer(client_id);
bool writer = result == ESP_OK; bool writer = result == ESP_OK;
@@ -594,13 +624,25 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
goto cleanup; goto cleanup;
} }
principal_current = false;
result = user_database_principal_is_current(&principal, &principal_current);
if (result != ESP_OK || !principal_current) {
close_unpublished_broker_session(slot, slot_generation, client_id);
result = ESP_FAIL;
goto cleanup;
}
bool activated = false; bool activated = false;
int64_t next_currentness_check_us =
monotonic_time_us() + WEB_SERIAL_CURRENTNESS_INTERVAL_US;
taskENTER_CRITICAL(&s_lock); taskENTER_CRITICAL(&s_lock);
if (slot->state == WEB_SERIAL_SLOT_RESERVED && if (slot->state == WEB_SERIAL_SLOT_RESERVED &&
slot->generation == slot_generation && slot->generation == slot_generation && !slot->close_requested &&
s_server == request->handle) { s_server == request->handle) {
slot->state = WEB_SERIAL_SLOT_ACTIVE; slot->state = WEB_SERIAL_SLOT_ACTIVE;
slot->broker_client_id = client_id; slot->broker_client_id = client_id;
slot->principal = principal;
slot->next_currentness_check_us = next_currentness_check_us;
slot->writer = writer; slot->writer = writer;
slot->hello_pending = true; slot->hello_pending = true;
++s_counters.connections; ++s_counters.connections;
@@ -622,13 +664,15 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
result = ESP_OK; result = ESP_OK;
cleanup: cleanup:
secure_wipe(&principal, sizeof(principal));
secure_wipe(ticket, sizeof(ticket)); secure_wipe(ticket, sizeof(ticket));
return result; return result;
} }
static bool capture_active_session(httpd_req_t *request, web_serial_slot_t **slot_out, static bool capture_active_session(httpd_req_t *request, web_serial_slot_t **slot_out,
uint32_t *generation, uint32_t *generation,
session_broker_client_id_t *client_id) session_broker_client_id_t *client_id,
user_principal_t *principal)
{ {
web_serial_slot_t *slot = request->sess_ctx; web_serial_slot_t *slot = request->sess_ctx;
int socket_fd = httpd_req_to_sockfd(request); int socket_fd = httpd_req_to_sockfd(request);
@@ -647,6 +691,7 @@ static bool capture_active_session(httpd_req_t *request, web_serial_slot_t **slo
*slot_out = slot; *slot_out = slot;
*generation = slot->generation; *generation = slot->generation;
*client_id = slot->broker_client_id; *client_id = slot->broker_client_id;
*principal = slot->principal;
} }
taskEXIT_CRITICAL(&s_lock); taskEXIT_CRITICAL(&s_lock);
return valid; return valid;
@@ -757,7 +802,9 @@ static esp_err_t process_websocket_frame(httpd_req_t *request)
web_serial_slot_t *slot = NULL; web_serial_slot_t *slot = NULL;
uint32_t generation = 0U; uint32_t generation = 0U;
session_broker_client_id_t client_id = SESSION_BROKER_NO_CLIENT; session_broker_client_id_t client_id = SESSION_BROKER_NO_CLIENT;
if (!capture_active_session(request, &slot, &generation, &client_id)) { user_principal_t principal = {0};
if (!capture_active_session(request, &slot, &generation, &client_id,
&principal)) {
add_counter(&s_counters.protocol_errors, 1U); add_counter(&s_counters.protocol_errors, 1U);
return ESP_FAIL; return ESP_FAIL;
} }
@@ -765,27 +812,52 @@ static esp_err_t process_websocket_frame(httpd_req_t *request)
int socket_fd = httpd_req_to_sockfd(request); int socket_fd = httpd_req_to_sockfd(request);
if (httpd_ws_get_fd_info(request->handle, socket_fd) != if (httpd_ws_get_fd_info(request->handle, socket_fd) !=
HTTPD_WS_CLIENT_WEBSOCKET) { HTTPD_WS_CLIENT_WEBSOCKET) {
secure_wipe(&principal, sizeof(principal));
return reject_protocol_frame(slot, generation, 0U); return reject_protocol_frame(slot, generation, 0U);
} }
httpd_ws_frame_t frame = {0}; httpd_ws_frame_t frame = {0};
esp_err_t result = httpd_ws_recv_frame(request, &frame, 0U); esp_err_t result = httpd_ws_recv_frame(request, &frame, 0U);
if (result != ESP_OK) { if (result != ESP_OK) {
secure_wipe(&principal, sizeof(principal));
return reject_protocol_frame(slot, generation, frame.len); return reject_protocol_frame(slot, generation, frame.len);
} }
if (!frame.final || frame.type == HTTPD_WS_TYPE_CONTINUE || if (!frame.final || frame.type == HTTPD_WS_TYPE_CONTINUE ||
frame.len > WEB_SERIAL_TRANSPORT_MAX_RX_PAYLOAD || frame.len > WEB_SERIAL_TRANSPORT_MAX_RX_PAYLOAD ||
(frame.type != HTTPD_WS_TYPE_BINARY && (frame.type != HTTPD_WS_TYPE_BINARY &&
frame.type != HTTPD_WS_TYPE_TEXT)) { frame.type != HTTPD_WS_TYPE_TEXT)) {
secure_wipe(&principal, sizeof(principal));
return reject_protocol_frame(slot, generation, frame.len); return reject_protocol_frame(slot, generation, frame.len);
} }
frame.payload = slot->rx_data; frame.payload = slot->rx_data;
result = httpd_ws_recv_frame(request, &frame, sizeof(slot->rx_data)); result = httpd_ws_recv_frame(request, &frame, sizeof(slot->rx_data));
if (result != ESP_OK) { if (result != ESP_OK) {
secure_wipe(&principal, sizeof(principal));
return reject_protocol_frame(slot, generation, frame.len); return reject_protocol_frame(slot, generation, frame.len);
} }
bool current = false;
esp_err_t currentness_result =
user_database_principal_is_current(&principal, &current);
secure_wipe(&principal, sizeof(principal));
if (currentness_result != ESP_OK || !current) {
request_handler_close(slot, generation);
return ESP_FAIL;
}
taskENTER_CRITICAL(&s_lock);
bool still_active = slot->state == WEB_SERIAL_SLOT_ACTIVE &&
!slot->close_requested &&
slot->generation == generation &&
slot->broker_client_id == client_id &&
slot->server == request->handle &&
slot->server == s_server;
taskEXIT_CRITICAL(&s_lock);
if (!still_active) {
return ESP_FAIL;
}
if (frame.type == HTTPD_WS_TYPE_BINARY) { if (frame.type == HTTPD_WS_TYPE_BINARY) {
return process_binary_frame(slot, generation, client_id, return process_binary_frame(slot, generation, client_id,
slot->rx_data, frame.len); slot->rx_data, frame.len);
@@ -817,19 +889,31 @@ static void web_serial_send_work(void *argument)
httpd_ws_type_t type; httpd_ws_type_t type;
size_t length; size_t length;
bool valid; bool valid;
bool retired = false;
taskENTER_CRITICAL(&s_lock); taskENTER_CRITICAL(&s_lock);
valid = slot->work_pending && work == &slot->work && bool owned_work = slot->work_pending && work == &slot->work &&
work->generation == slot->generation && work->generation == slot->generation &&
work->server == slot->server && work->server == slot->server &&
work->socket_fd == slot->socket_fd; work->socket_fd == slot->socket_fd;
valid = owned_work && slot->state == WEB_SERIAL_SLOT_ACTIVE &&
!slot->close_requested;
server = work->server; server = work->server;
socket_fd = work->socket_fd; socket_fd = work->socket_fd;
generation = work->generation; generation = work->generation;
type = slot->tx_type; type = slot->tx_type;
length = slot->tx_length; length = slot->tx_length;
if (owned_work && !valid) {
slot->work_pending = false;
slot->tx_length = 0U;
finish_closing_slot_locked(slot);
retired = true;
}
taskEXIT_CRITICAL(&s_lock); taskEXIT_CRITICAL(&s_lock);
if (!valid) { if (!valid) {
if (retired) {
notify_transport_task();
}
return; return;
} }
@@ -1113,6 +1197,8 @@ static void process_close_request(web_serial_slot_t *slot)
++s_counters.queue_failures; ++s_counters.queue_failures;
if (session_gone) { if (session_gone) {
slot->state = WEB_SERIAL_SLOT_CLOSING; slot->state = WEB_SERIAL_SLOT_CLOSING;
secure_wipe(&slot->principal, sizeof(slot->principal));
slot->next_currentness_check_us = 0;
slot->writer = false; slot->writer = false;
slot->hello_pending = false; slot->hello_pending = false;
slot->work_pending = false; slot->work_pending = false;
@@ -1164,6 +1250,42 @@ static void process_broker_disconnect(web_serial_slot_t *slot)
taskEXIT_CRITICAL(&s_lock); taskEXIT_CRITICAL(&s_lock);
} }
static void process_principal_currentness(web_serial_slot_t *slot)
{
user_principal_t principal = {0};
uint32_t generation = 0U;
bool check = false;
int64_t now_us = monotonic_time_us();
taskENTER_CRITICAL(&s_lock);
if (slot->state == WEB_SERIAL_SLOT_ACTIVE && !slot->close_requested &&
slot->next_currentness_check_us <= now_us) {
generation = slot->generation;
principal = slot->principal;
slot->next_currentness_check_us =
now_us + WEB_SERIAL_CURRENTNESS_INTERVAL_US;
check = true;
}
taskEXIT_CRITICAL(&s_lock);
if (!check) {
return;
}
bool current = false;
esp_err_t result = user_database_principal_is_current(&principal, &current);
secure_wipe(&principal, sizeof(principal));
if (result == ESP_OK && current) {
return;
}
taskENTER_CRITICAL(&s_lock);
if (slot->state == WEB_SERIAL_SLOT_ACTIVE &&
slot->generation == generation) {
slot->close_requested = true;
}
taskEXIT_CRITICAL(&s_lock);
}
static void process_active_output(web_serial_slot_t *slot) static void process_active_output(web_serial_slot_t *slot)
{ {
uint32_t generation; uint32_t generation;
@@ -1221,6 +1343,7 @@ static void transport_task(void *context)
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_SESSIONS;
++index) { ++index) {
web_serial_slot_t *slot = &s_slots[index]; web_serial_slot_t *slot = &s_slots[index];
process_principal_currentness(slot);
process_close_request(slot); process_close_request(slot);
process_broker_disconnect(slot); process_broker_disconnect(slot);
process_active_output(slot); process_active_output(slot);
@@ -1332,6 +1455,8 @@ esp_err_t web_serial_transport_detach_server(httpd_handle_t server)
slot->tx_length = 0U; slot->tx_length = 0U;
if (slot->state == WEB_SERIAL_SLOT_ACTIVE) { if (slot->state == WEB_SERIAL_SLOT_ACTIVE) {
slot->state = WEB_SERIAL_SLOT_CLOSING; slot->state = WEB_SERIAL_SLOT_CLOSING;
secure_wipe(&slot->principal, sizeof(slot->principal));
slot->next_currentness_check_us = 0;
slot->writer = false; slot->writer = false;
slot->hello_pending = false; slot->hello_pending = false;
slot->close_requested = false; slot->close_requested = false;
@@ -1398,20 +1523,27 @@ esp_err_t web_serial_transport_detach_server(httpd_handle_t server)
return result; return result;
} }
esp_err_t web_serial_transport_mint_ticket(char *ticket, size_t capacity) esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal,
char *ticket, size_t capacity)
{ {
if (ticket == NULL || capacity < WEB_SERIAL_TRANSPORT_TICKET_CAPACITY) { if (principal == NULL || ticket == NULL ||
capacity < WEB_SERIAL_TRANSPORT_TICKET_CAPACITY) {
return ESP_ERR_INVALID_ARG; return ESP_ERR_INVALID_ARG;
} }
ticket[0] = '\0'; ticket[0] = '\0';
bool current = false;
esp_err_t result = user_database_principal_is_current(principal, &current);
if (result != ESP_OK) {
return result;
}
if (!current) {
return ESP_ERR_INVALID_STATE;
}
uint8_t random_bytes[WEB_SERIAL_RANDOM_BYTES] = {0}; uint8_t random_bytes[WEB_SERIAL_RANDOM_BYTES] = {0};
uint8_t digest[WEB_SERIAL_SHA256_BYTES] = {0}; uint8_t digest[WEB_SERIAL_SHA256_BYTES] = {0};
uint32_t generation = 0U; result = secure_random_fill(random_bytes, sizeof(random_bytes));
esp_err_t result = current_material_generation(&generation);
if (result == ESP_OK) {
result = secure_random_fill(random_bytes, sizeof(random_bytes));
}
if (result == ESP_OK) { if (result == ESP_OK) {
encode_base64url_24(random_bytes, ticket); encode_base64url_24(random_bytes, ticket);
result = sha256_ticket(ticket, digest); result = sha256_ticket(ticket, digest);
@@ -1425,7 +1557,7 @@ esp_err_t web_serial_transport_mint_ticket(char *ticket, size_t capacity)
bool stored = false; bool stored = false;
taskENTER_CRITICAL(&s_lock); taskENTER_CRITICAL(&s_lock);
if (s_initialized && s_server != NULL) { if (s_initialized && s_server != NULL) {
purge_tickets_locked(now_us, generation, true); purge_tickets_locked(now_us);
size_t selected = WEB_SERIAL_TRANSPORT_MAX_TICKETS; size_t selected = WEB_SERIAL_TRANSPORT_MAX_TICKETS;
int64_t oldest_expiry = INT64_MAX; int64_t oldest_expiry = INT64_MAX;
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_TICKETS; for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_TICKETS;
@@ -1447,7 +1579,7 @@ esp_err_t web_serial_transport_mint_ticket(char *ticket, size_t capacity)
entry->expires_at_us = entry->expires_at_us =
now_us + (int64_t)WEB_SERIAL_TRANSPORT_TICKET_LIFETIME_SECONDS * now_us + (int64_t)WEB_SERIAL_TRANSPORT_TICKET_LIFETIME_SECONDS *
1000000LL; 1000000LL;
entry->material_generation = generation; entry->principal = *principal;
entry->active = true; entry->active = true;
++s_counters.tickets_issued; ++s_counters.tickets_issued;
stored = true; stored = true;
@@ -1467,9 +1599,9 @@ cleanup:
} }
esp_err_t web_serial_transport_handle_authenticated_ticket_request( esp_err_t web_serial_transport_handle_authenticated_ticket_request(
httpd_req_t *request) httpd_req_t *request, const user_principal_t *principal)
{ {
if (request == NULL) { if (request == NULL || principal == NULL) {
return ESP_ERR_INVALID_ARG; return ESP_ERR_INVALID_ARG;
} }
if (request->method != HTTP_POST || request->content_len != 0U || if (request->method != HTTP_POST || request->content_len != 0U ||
@@ -1487,7 +1619,8 @@ esp_err_t web_serial_transport_handle_authenticated_ticket_request(
char ticket[WEB_SERIAL_TRANSPORT_TICKET_CAPACITY] = {0}; char ticket[WEB_SERIAL_TRANSPORT_TICKET_CAPACITY] = {0};
char response[WEB_SERIAL_TICKET_RESPONSE_CAPACITY]; char response[WEB_SERIAL_TICKET_RESPONSE_CAPACITY];
esp_err_t result = web_serial_transport_mint_ticket(ticket, sizeof(ticket)); esp_err_t result = web_serial_transport_mint_ticket(
principal, ticket, sizeof(ticket));
if (result != ESP_OK) { if (result != ESP_OK) {
secure_wipe(ticket, sizeof(ticket)); secure_wipe(ticket, sizeof(ticket));
return result; return result;
@@ -1573,8 +1706,6 @@ esp_err_t web_serial_transport_get_snapshot(
return ESP_ERR_INVALID_ARG; return ESP_ERR_INVALID_ARG;
} }
uint32_t generation = 0U;
bool have_generation = current_material_generation(&generation) == ESP_OK;
int64_t now_us = monotonic_time_us(); int64_t now_us = monotonic_time_us();
taskENTER_CRITICAL(&s_lock); taskENTER_CRITICAL(&s_lock);
@@ -1582,7 +1713,7 @@ esp_err_t web_serial_transport_get_snapshot(
taskEXIT_CRITICAL(&s_lock); taskEXIT_CRITICAL(&s_lock);
return ESP_ERR_INVALID_STATE; return ESP_ERR_INVALID_STATE;
} }
purge_tickets_locked(now_us, generation, have_generation); purge_tickets_locked(now_us);
memset(snapshot, 0, sizeof(*snapshot)); memset(snapshot, 0, sizeof(*snapshot));
snapshot->initialized = true; snapshot->initialized = true;
snapshot->server_attached = s_server != NULL; snapshot->server_attached = s_server != NULL;
@@ -1602,12 +1733,17 @@ esp_err_t web_serial_transport_get_snapshot(
continue; continue;
} }
session->active = true; session->active = true;
session->principal_valid = true;
session->writer = slot->writer; session->writer = slot->writer;
session->tx_pending = slot->work_pending; session->tx_pending = slot->work_pending;
session->close_requested = slot->close_requested; session->close_requested = slot->close_requested;
session->socket_fd = slot->socket_fd; session->socket_fd = slot->socket_fd;
session->generation = slot->generation; session->generation = slot->generation;
session->broker_client_id = slot->broker_client_id; session->broker_client_id = slot->broker_client_id;
session->user_role = slot->principal.role;
session->auth_method = slot->principal.method;
memcpy(session->username, slot->principal.username,
slot->principal.username_length);
++snapshot->active_sessions; ++snapshot->active_sessions;
} }
taskEXIT_CRITICAL(&s_lock); taskEXIT_CRITICAL(&s_lock);
@@ -1626,6 +1762,43 @@ esp_err_t web_serial_transport_clear_counters(void)
return ESP_OK; return ESP_OK;
} }
esp_err_t web_serial_transport_revoke_user(const uint8_t *username,
size_t username_length)
{
if (!user_database_username_valid(username, username_length)) {
return ESP_ERR_INVALID_ARG;
}
bool notify = false;
taskENTER_CRITICAL(&s_lock);
if (!s_initialized) {
taskEXIT_CRITICAL(&s_lock);
return ESP_ERR_INVALID_STATE;
}
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_TICKETS; ++index) {
web_serial_ticket_t *ticket = &s_tickets[index];
if (ticket->active && ticket->principal.username_length == username_length &&
memcmp(ticket->principal.username, username, username_length) == 0) {
clear_ticket_locked(ticket);
}
}
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; ++index) {
web_serial_slot_t *slot = &s_slots[index];
if ((slot->state == WEB_SERIAL_SLOT_RESERVED ||
slot->state == WEB_SERIAL_SLOT_ACTIVE) &&
slot->principal.username_length == username_length &&
memcmp(slot->principal.username, username, username_length) == 0) {
slot->close_requested = true;
notify = true;
}
}
taskEXIT_CRITICAL(&s_lock);
if (notify) {
notify_transport_task();
}
return ESP_OK;
}
esp_err_t web_serial_transport_revoke_sessions(void) esp_err_t web_serial_transport_revoke_sessions(void)
{ {
taskENTER_CRITICAL(&s_lock); taskENTER_CRITICAL(&s_lock);
+16 -8
View File
@@ -10,6 +10,7 @@
#include "esp_err.h" #include "esp_err.h"
#include "esp_http_server.h" #include "esp_http_server.h"
#include "session_broker.h" #include "session_broker.h"
#include "user_database.h"
#ifdef __cplusplus #ifdef __cplusplus
extern "C" { extern "C" {
@@ -63,12 +64,16 @@ typedef struct {
typedef struct { typedef struct {
bool active; bool active;
bool principal_valid;
bool writer; bool writer;
bool tx_pending; bool tx_pending;
bool close_requested; bool close_requested;
int socket_fd; int socket_fd;
uint32_t generation; uint32_t generation;
session_broker_client_id_t broker_client_id; session_broker_client_id_t broker_client_id;
user_role_t user_role;
user_auth_method_t auth_method;
char username[USER_DATABASE_USERNAME_CAPACITY + 1U];
} web_serial_transport_session_snapshot_t; } web_serial_transport_session_snapshot_t;
typedef struct { typedef struct {
@@ -93,19 +98,20 @@ esp_err_t web_serial_transport_attach_server(httpd_handle_t server);
esp_err_t web_serial_transport_detach_server(httpd_handle_t server); esp_err_t web_serial_transport_detach_server(httpd_handle_t server);
/* /*
* Mint a one-time bearer ticket for an already-authenticated caller. The output * Mint a one-time bearer ticket bound to a current authenticated principal. The
* is exactly 32 Base64URL characters plus a terminator and expires after 30 * principal is copied; the output is exactly 32 Base64URL characters plus a
* monotonic seconds. Never log or persist the returned value. * terminator and expires after 30 monotonic seconds. Never log or persist it.
*/ */
esp_err_t web_serial_transport_mint_ticket(char *ticket, size_t capacity); esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal,
char *ticket, size_t capacity);
/* /*
* Convenience POST response helper for /api/ws-ticket. Authentication is * Convenience POST response helper for /api/ws-ticket. Authentication is
* intentionally outside this module: call this only after Basic authentication * intentionally outside this module: pass the principal returned by successful
* has already succeeded. Register it as HTTP_POST, not as a public handler. * Basic authentication. Register it as HTTP_POST, not as a public handler.
*/ */
esp_err_t web_serial_transport_handle_authenticated_ticket_request( esp_err_t web_serial_transport_handle_authenticated_ticket_request(
httpd_req_t *request); httpd_req_t *request, const user_principal_t *principal);
/* /*
* Handler for /ws/serial. Register as HTTP_GET with is_websocket=true and * Handler for /ws/serial. Register as HTTP_GET with is_websocket=true and
@@ -120,7 +126,9 @@ esp_err_t web_serial_transport_get_snapshot(
/* Clearing counters does not alter tickets, sessions, ownership, or queued data. */ /* Clearing counters does not alter tickets, sessions, ownership, or queued data. */
esp_err_t web_serial_transport_clear_counters(void); esp_err_t web_serial_transport_clear_counters(void);
/* Invalidate outstanding tickets and close authenticated web serial sessions. */ /* Invalidate tickets/sessions for one account, or all authenticated sessions. */
esp_err_t web_serial_transport_revoke_user(const uint8_t *username,
size_t username_length);
esp_err_t web_serial_transport_revoke_sessions(void); esp_err_t web_serial_transport_revoke_sessions(void);
#ifdef __cplusplus #ifdef __cplusplus
+35 -12
View File
@@ -20,14 +20,19 @@
#include "serial_service.h" #include "serial_service.h"
#include "session_broker.h" #include "session_broker.h"
#include "usb_cdc_transport.h" #include "usb_cdc_transport.h"
#include "user_database.h"
#include "web_security.h" #include "web_security.h"
#include "web_serial_transport.h" #include "web_serial_transport.h"
#include "web_ui.h" #include "web_ui.h"
#include "wifi_manager.h" #include "wifi_manager.h"
#define WEB_SERVER_PORT 443U #define WEB_SERVER_PORT 443U
#define WEB_SERVER_MAX_AUTHORIZATION 128U #define WEB_SERVER_MAX_BASIC_DECODED \
#define WEB_SERVER_MAX_BASIC_DECODED 64U (USER_DATABASE_USERNAME_CAPACITY + 1U + USER_DATABASE_PASSWORD_CAPACITY)
#define WEB_SERVER_MAX_BASIC_ENCODED \
(((WEB_SERVER_MAX_BASIC_DECODED + 2U) / 3U) * 4U)
#define WEB_SERVER_MAX_AUTHORIZATION \
((sizeof("Basic ") - 1U) + WEB_SERVER_MAX_BASIC_ENCODED + 1U)
#define WEB_SERVER_STATUS_JSON_CAPACITY 3072U #define WEB_SERVER_STATUS_JSON_CAPACITY 3072U
static SemaphoreHandle_t s_server_mutex; static SemaphoreHandle_t s_server_mutex;
@@ -101,12 +106,15 @@ static esp_err_t send_authentication_required(httpd_req_t *request)
return send_plain_error(request, "401 Unauthorized", "Authentication required.\n"); return send_plain_error(request, "401 Unauthorized", "Authentication required.\n");
} }
static esp_err_t authenticate_request(httpd_req_t *request, bool *authenticated) static esp_err_t authenticate_request(httpd_req_t *request,
user_principal_t *principal,
bool *authenticated)
{ {
char authorization[WEB_SERVER_MAX_AUTHORIZATION] = {0}; char authorization[WEB_SERVER_MAX_AUTHORIZATION] = {0};
uint8_t decoded[WEB_SERVER_MAX_BASIC_DECODED] = {0}; uint8_t decoded[WEB_SERVER_MAX_BASIC_DECODED] = {0};
size_t decoded_length = 0U; size_t decoded_length = 0U;
esp_err_t result = ESP_OK; esp_err_t result = ESP_OK;
memset(principal, 0, sizeof(*principal));
*authenticated = false; *authenticated = false;
increment_counter(&s_counters.requests); increment_counter(&s_counters.requests);
@@ -133,28 +141,32 @@ static esp_err_t authenticate_request(httpd_req_t *request, bool *authenticated)
} }
size_t username_length = (size_t)(separator - decoded); size_t username_length = (size_t)(separator - decoded);
size_t password_length = decoded_length - username_length - 1U; size_t password_length = decoded_length - username_length - 1U;
result = web_security_authenticate_basic(decoded, username_length, result = user_database_authenticate_password(
separator + 1U, password_length, decoded, username_length, separator + 1U, password_length,
authenticated); principal, authenticated);
cleanup: cleanup:
secure_wipe(authorization, sizeof(authorization)); secure_wipe(authorization, sizeof(authorization));
secure_wipe(decoded, sizeof(decoded)); secure_wipe(decoded, sizeof(decoded));
if (result != ESP_OK) { if (result != ESP_OK) {
memset(principal, 0, sizeof(*principal));
return result; return result;
} }
if (*authenticated) { if (*authenticated) {
increment_counter(&s_counters.authenticated_requests); increment_counter(&s_counters.authenticated_requests);
} else { } else {
memset(principal, 0, sizeof(*principal));
increment_counter(&s_counters.authentication_failures); increment_counter(&s_counters.authentication_failures);
} }
return ESP_OK; return ESP_OK;
} }
static esp_err_t authorize_or_respond(httpd_req_t *request, bool *authorized) static esp_err_t authorize_or_respond(httpd_req_t *request,
user_principal_t *principal,
bool *authorized)
{ {
*authorized = false; *authorized = false;
esp_err_t error = authenticate_request(request, authorized); esp_err_t error = authenticate_request(request, principal, authorized);
if (error != ESP_OK) { if (error != ESP_OK) {
*authorized = false; *authorized = false;
return send_plain_error(request, "503 Service Unavailable", return send_plain_error(request, "503 Service Unavailable",
@@ -167,14 +179,17 @@ static esp_err_t send_authenticated_ui(httpd_req_t *request,
web_ui_resource_t resource, web_ui_resource_t resource,
uint64_t *counter) uint64_t *counter)
{ {
user_principal_t principal = {0};
bool authorized = false; bool authorized = false;
esp_err_t error = authorize_or_respond(request, &authorized); esp_err_t error = authorize_or_respond(request, &principal, &authorized);
if (error != ESP_OK || !authorized) { if (error != ESP_OK || !authorized) {
secure_wipe(&principal, sizeof(principal));
return error; return error;
} }
increment_counter(counter); increment_counter(counter);
error = web_ui_send_response(request, resource); error = web_ui_send_response(request, resource);
secure_wipe(&principal, sizeof(principal));
if (error != ESP_OK) { if (error != ESP_OK) {
increment_counter(&s_counters.response_errors); increment_counter(&s_counters.response_errors);
} }
@@ -195,18 +210,23 @@ static esp_err_t asset_handler(httpd_req_t *request)
static esp_err_t ticket_handler(httpd_req_t *request) static esp_err_t ticket_handler(httpd_req_t *request)
{ {
user_principal_t principal = {0};
bool authorized = false; bool authorized = false;
esp_err_t error = authorize_or_respond(request, &authorized); esp_err_t error = authorize_or_respond(request, &principal, &authorized);
if (error != ESP_OK || !authorized) { if (error != ESP_OK || !authorized) {
secure_wipe(&principal, sizeof(principal));
return error; return error;
} }
increment_counter(&s_counters.ticket_requests); increment_counter(&s_counters.ticket_requests);
if (request->content_len != 0U) { if (request->content_len != 0U) {
secure_wipe(&principal, sizeof(principal));
return send_plain_error(request, "400 Bad Request", return send_plain_error(request, "400 Bad Request",
"Ticket requests must have an empty body.\n"); "Ticket requests must have an empty body.\n");
} }
error = web_serial_transport_handle_authenticated_ticket_request(request); error = web_serial_transport_handle_authenticated_ticket_request(
request, &principal);
secure_wipe(&principal, sizeof(principal));
if (error == ESP_OK) { if (error == ESP_OK) {
return ESP_OK; return ESP_OK;
} }
@@ -254,11 +274,14 @@ static void format_fingerprint(const uint8_t fingerprint[WEB_SECURITY_SHA256_LEN
static esp_err_t status_handler(httpd_req_t *request) static esp_err_t status_handler(httpd_req_t *request)
{ {
user_principal_t principal = {0};
bool authorized = false; bool authorized = false;
esp_err_t error = authorize_or_respond(request, &authorized); esp_err_t error = authorize_or_respond(request, &principal, &authorized);
if (error != ESP_OK || !authorized) { if (error != ESP_OK || !authorized) {
secure_wipe(&principal, sizeof(principal));
return error; return error;
} }
secure_wipe(&principal, sizeof(principal));
increment_counter(&s_counters.status_requests); increment_counter(&s_counters.status_requests);
wifi_manager_snapshot_t wifi = {0}; wifi_manager_snapshot_t wifi = {0};