Implemented initial SSH support. Memory pressure too high for HTTPS and
SSH. Dirty commit.
This commit is contained in:
@@ -1,5 +1,8 @@
|
|||||||
cmake_minimum_required(VERSION 3.16)
|
cmake_minimum_required(VERSION 3.16)
|
||||||
|
|
||||||
|
# Route wolfCrypt seeding through the project's sole pre-radio DRBG callback.
|
||||||
|
add_compile_definitions(WC_RNG_SEED_CB)
|
||||||
|
|
||||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
set(PROJECT_VER "0.1.0")
|
set(PROJECT_VER "0.1.0")
|
||||||
project(esp32_serial_swiss_army_knife)
|
project(esp32_serial_swiss_army_knife)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Universal wireless serial adaptor firmware for the ESP32-S3.
|
|||||||
- 8 MB octal PSRAM
|
- 8 MB octal PSRAM
|
||||||
- Adafruit MAX3243 full-pinout RS-232 breakout, product 5988
|
- Adafruit MAX3243 full-pinout RS-232 breakout, product 5988
|
||||||
|
|
||||||
The firmware has completed **Phase 0 hardware characterization**, the **Phase 1 serial-core foundation**, the **Phase 2 transport-neutral session broker**, native USB CDC-ACM, the **Phase 4 Wi-Fi foundation**, and the **Phase 5 authenticated HTTPS web terminal**. Phase 5B adds an offline xterm.js interface and bounded WebSocket transport to the persistent HTTPS identity and recovery foundation from Phase 5A. The MAX3243 diagnostics and recovery consoles remain available. No electrical test starts automatically; UART1 starts when requested explicitly or when a native USB or authenticated web-terminal session opens.
|
The firmware has completed **Phase 0 hardware characterization**, the **Phase 1 serial-core foundation**, the **Phase 2 transport-neutral session broker**, native USB CDC-ACM, the **Phase 4 Wi-Fi foundation**, the **Phase 5 authenticated HTTPS web terminal**, and the **Phase 6 authenticated SSH serial transport**. Phase 5B provides an offline xterm.js interface and bounded WebSocket transport; Phase 6 adds a bounded, password-authenticated, shell-only SSH path with an independent persistent host identity. The MAX3243 diagnostics and recovery consoles remain available. No electrical test starts automatically; UART1 starts when requested explicitly or when native USB, an authenticated web terminal, or an authenticated SSH session opens.
|
||||||
|
|
||||||
## Hardware wiring
|
## Hardware wiring
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ The N16R8 target has 16 MiB flash and 8 MiB octal PSRAM. PlatformIO uses the cus
|
|||||||
|
|
||||||
| Partition | Offset | Size | Purpose |
|
| Partition | Offset | Size | Purpose |
|
||||||
|---|---:|---:|---|
|
|---|---:|---:|---|
|
||||||
| `nvs` | `0x009000` | 512 KiB | Serial, Wi-Fi, HTTPS credential, and certificate/key data |
|
| `nvs` | `0x009000` | 512 KiB | Serial, Wi-Fi, shared admin credential, HTTPS identity, and SSH host-key data |
|
||||||
| `otadata` | `0x089000` | 8 KiB | Active OTA-slot selection metadata |
|
| `otadata` | `0x089000` | 8 KiB | Active OTA-slot selection metadata |
|
||||||
| `phy_init` | `0x08B000` | 4 KiB | Optional PHY initialization data |
|
| `phy_init` | `0x08B000` | 4 KiB | Optional PHY initialization data |
|
||||||
| `nvs_key` | `0x08C000` | 4 KiB | Reserved for future encrypted-NVS keys |
|
| `nvs_key` | `0x08C000` | 4 KiB | Reserved for future encrypted-NVS keys |
|
||||||
@@ -68,7 +68,7 @@ pio device monitor -b 115200
|
|||||||
|
|
||||||
The firmware starts an interactive console on UART0 with the prompt `serial-tool>`. Type `help` to display concise root-command descriptions. This USB-to-UART device normally appears as `/dev/ttyUSB*`; it is separate from the native USB CDC serial transport described below.
|
The firmware starts an interactive console on UART0 with the prompt `serial-tool>`. Type `help` to display concise root-command descriptions. This USB-to-UART device normally appears as `/dev/ttyUSB*`; it is separate from the native USB CDC serial transport described below.
|
||||||
|
|
||||||
The console provides line editing, history for the current session, terminal-aware cursor movement, and Tab completion. ESP-IDF supplies completion for root commands; the project extends it to nested `debug`, `serial`, `broker`, `usb`, `wifi`, and `web` subcommands plus safe fixed values such as AP policy and serial framing. Password values are intentionally never completion candidates.
|
The console provides line editing, history for the current session, terminal-aware cursor movement, and Tab completion. ESP-IDF supplies completion for root commands; the project extends it to nested `debug`, `serial`, `broker`, `usb`, `wifi`, `web`, and `ssh` subcommands plus safe fixed values such as AP policy and serial framing. Password values are intentionally never completion candidates.
|
||||||
|
|
||||||
ESP-IDF normally probes terminal cursor support once while constructing the UART REPL. If the board boots without a terminal attached, that probe times out and selects dumb mode. The DevKit's USB-to-UART bridge exposes no host-open signal that firmware can monitor, and entering enhanced mode before a terminal exists would block linenoise while it waits for a cursor-position response.
|
ESP-IDF normally probes terminal cursor support once while constructing the UART REPL. If the board boots without a terminal attached, that probe times out and selects dumb mode. The DevKit's USB-to-UART bridge exposes no host-open signal that firmware can monitor, and entering enhanced mode before a terminal exists would block linenoise while it waits for a cursor-position response.
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ UART1 has exclusive ownership while the service runs. Phase 0 commands will refu
|
|||||||
|
|
||||||
### Phase 2 session broker
|
### Phase 2 session broker
|
||||||
|
|
||||||
The broker is initialized at boot and continuously drains the serial service whenever UART1 is running. It is transport-neutral: console test clients and native USB CDC use the same API that WebSocket and SSH transports will use later.
|
The broker is initialized at boot and continuously drains the serial service whenever UART1 is running. It is transport-neutral: console test clients, native USB CDC, authenticated WebSocket terminals, and authenticated SSH sessions all use the same API.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
broker status
|
broker status
|
||||||
@@ -208,7 +208,7 @@ Power down and remove the DE-9 pin 3-to-2 jumper before connecting an external s
|
|||||||
|
|
||||||
### Wi-Fi foundation
|
### Wi-Fi foundation
|
||||||
|
|
||||||
Wi-Fi is managed independently of the serial-session broker. It provides network connectivity and recovery access-point policy for the HTTPS service. The firmware does not run plaintext HTTP, DNS interception, a captive portal, NAPT, or any TCP serial listener.
|
Wi-Fi is managed independently of the serial-session broker. It provides network connectivity and recovery access-point policy for the HTTPS and SSH services. The firmware does not run plaintext HTTP, DNS interception, a captive portal, NAPT, or any unauthenticated/plaintext TCP serial listener.
|
||||||
|
|
||||||
Configuration uses four fixed station-profile slots. Lower numeric priority values are tried first, with slot number breaking ties. Profiles support WPA2/WPA3 mixed operation or require WPA3-SAE. ESP-IDF's station threshold can express “WPA2 or stronger” but not a strict WPA2-only maximum, so the configuration does not pretend to offer a distinct WPA2-only mode. Each profile attempt has a 12-second association/DHCP deadline. After all enabled profiles fail, the manager uses exponential retry delays from 2 to 60 seconds.
|
Configuration uses four fixed station-profile slots. Lower numeric priority values are tried first, with slot number breaking ties. Profiles support WPA2/WPA3 mixed operation or require WPA3-SAE. ESP-IDF's station threshold can express “WPA2 or stronger” but not a strict WPA2-only maximum, so the configuration does not pretend to offer a distinct WPA2-only mode. Each profile attempt has a 12-second association/DHCP deadline. After all enabled profiles fail, the manager uses exponential retry delays from 2 to 60 seconds.
|
||||||
|
|
||||||
@@ -294,12 +294,13 @@ The document CSP permits scripts only from the device itself. xterm.js requires
|
|||||||
|
|
||||||
On first boot, the device generates and persists:
|
On first boot, the device generates and persists:
|
||||||
|
|
||||||
- username `admin` and a random 24-character Base64URL-safe password;
|
- username `admin` and a random 24-character Base64URL-safe password shared by HTTPS and SSH;
|
||||||
- an ECDSA P-256 private key;
|
- an ECDSA P-256 HTTPS private key;
|
||||||
- a device-specific self-signed SHA-256 certificate valid from 2025-01-01 through 2049-12-31;
|
- a device-specific self-signed SHA-256 certificate valid from 2025-01-01 through 2049-12-31;
|
||||||
- certificate SANs for `192.168.4.1` and a MAC-suffixed name such as `esp32-sak-a1b2c3.local`.
|
- certificate SANs for `192.168.4.1` and a MAC-suffixed name such as `esp32-sak-a1b2c3.local`;
|
||||||
|
- a separate ECDSA P-256 SSH host key and OpenSSH-compatible SHA-256 fingerprint.
|
||||||
|
|
||||||
The certificate and credentials remain stable across ordinary reboot and OTA-slot changes until explicitly rotated. The `.local` name is included for future hostname discovery, but this phase does not yet advertise mDNS; use the AP address or the station address reported by `wifi status`.
|
The HTTPS certificate and shared administrative credentials remain stable across ordinary reboot and OTA-slot changes until explicitly rotated. The `.local` name is included for future hostname discovery, but this phase does not yet advertise mDNS; use the AP address or the station address reported by `wifi status`.
|
||||||
|
|
||||||
The physical UART0 administration console provides:
|
The physical UART0 administration console provides:
|
||||||
|
|
||||||
@@ -316,7 +317,7 @@ web certificate rotate --force
|
|||||||
web reset --force
|
web reset --force
|
||||||
```
|
```
|
||||||
|
|
||||||
`web` and `web help` print the same usage summary. `web credentials show` is the intended first-boot credential-retrieval path. Rotation and reset operations write NVS immediately rather than creating RAM-only secrets. Destructive operations require a literal `--force`; `web reset --force` is also the recovery path for an incompatible or damaged `web_sec/material` blob and starts HTTPS with the recovered material. Invalid stored material is never overwritten automatically, and HTTPS failure never disables UART0, native USB, the serial core, or Wi-Fi recovery.
|
`web` and `web help` print the same usage summary. `web credentials show` is the intended first-boot credential-retrieval path for both HTTPS and SSH. Rotation and reset operations write NVS immediately rather than creating RAM-only secrets. Credential rotation revokes active WebSocket and SSH sessions. Destructive operations require a literal `--force`; `web reset --force` is also the recovery path for an incompatible or damaged `web_sec/material` blob and starts HTTPS with the recovered material. Invalid stored material is never overwritten automatically, and HTTPS failure never disables UART0, native USB, the serial core, or Wi-Fi recovery.
|
||||||
|
|
||||||
The self-signed certificate is not trusted by browsers or host tools by default. After retrieving the password, validate from a host connected to the fallback AP with:
|
The self-signed certificate is not trusted by browsers or host tools by default. After retrieving the password, validate from a host connected to the fallback AP with:
|
||||||
|
|
||||||
@@ -342,7 +343,7 @@ Replace the example SNI name with the DNS SAN printed by `web certificate info`.
|
|||||||
|
|
||||||
#### WebSocket authentication and broker behavior
|
#### WebSocket authentication and broker behavior
|
||||||
|
|
||||||
Browser JavaScript cannot reliably attach a Basic `Authorization` header to a WebSocket constructor. The authenticated page therefore obtains a 192-bit random, RAM-only ticket with `POST /api/ws-ticket`, then presents that ticket once in the WSS URL. The server stores only its SHA-256 digest, accepts it once within 30 seconds, binds it to the current credential generation, and creates no broker client until validation succeeds. Credential rotation invalidates outstanding tickets and closes active web-terminal sessions.
|
Browser JavaScript cannot reliably attach a Basic `Authorization` header to a WebSocket constructor. The authenticated page therefore obtains a 192-bit random, RAM-only ticket with `POST /api/ws-ticket`, then presents that ticket once in the WSS URL. The server stores only its SHA-256 digest, accepts it once within 30 seconds, binds it to the current credential generation, and creates no broker client until validation succeeds. Credential rotation invalidates outstanding tickets and closes active web-terminal and SSH sessions.
|
||||||
|
|
||||||
ESP-IDF 5.5 sends the RFC 6455 `101 Switching Protocols` response before invoking the application WebSocket handler. Consequently, an invalid ticket receives the protocol upgrade and is then closed immediately rather than receiving an HTTP `401`; it never gains a broker session, serial output, or writer access. Strict rejection before `101` would require a framework-level pre-handshake authorization hook that ESP-IDF 5.5 does not provide.
|
ESP-IDF 5.5 sends the RFC 6455 `101 Switching Protocols` response before invoking the application WebSocket handler. Consequently, an invalid ticket receives the protocol upgrade and is then closed immediately rather than receiving an HTTP `401`; it never gains a broker session, serial output, or writer access. Strict rejection before `101` would require a framework-level pre-handshake authorization hook that ESP-IDF 5.5 does not provide.
|
||||||
|
|
||||||
@@ -372,7 +373,74 @@ Vendored browser sources, versions, hashes/provenance, deterministic gzip artifa
|
|||||||
|
|
||||||
HTTPS permits up to six simultaneous client sockets: two bounded persistent WebSocket terminals plus parallel browser asset, ticket, and status requests. ESP-IDF documents approximately 40 KiB per active TLS socket, so this is a concurrency ceiling rather than preallocated per-socket memory. WebSocket serial sessions themselves remain fixed at two. To preserve internal DRAM during concurrent handshakes, the firmware configures ESP-IDF's mbedTLS allocator for the board's external PSRAM. Basic authentication is acceptable here only because plaintext HTTP is disabled. It is an initial administration mechanism, not the final authorization design.
|
HTTPS permits up to six simultaneous client sockets: two bounded persistent WebSocket terminals plus parallel browser asset, ticket, and status requests. ESP-IDF documents approximately 40 KiB per active TLS socket, so this is a concurrency ceiling rather than preallocated per-socket memory. WebSocket serial sessions themselves remain fixed at two. To preserve internal DRAM during concurrent handshakes, the firmware configures ESP-IDF's mbedTLS allocator for the board's external PSRAM. Basic authentication is acceptable here only because plaintext HTTP is disabled. It is an initial administration mechanism, not the final authorization design.
|
||||||
|
|
||||||
**Current security limitation:** the web password and ECDSA private key are stored as plaintext in the application-owned `web_sec/material` NVS blob, just as Wi-Fi credentials are currently plaintext in `wifi_app/config`. The reserved `nvs_key` partition does not activate NVS encryption. ESP-IDF 5.5 also keeps active mbedTLS allocations, including TLS key/session material, in external PSRAM under this memory configuration and does not guarantee zeroization when those allocations are freed. PSRAM encryption is not enabled in the current firmware. Do not treat the current firmware as resistant to physical flash or RAM extraction; NVS encryption, flash/PSRAM encryption, secure boot, protected OTA, secret-aware core-dump handling, and framework-level key zeroization belong to the later hardening phase.
|
**Current security limitation:** the shared admin password and HTTPS ECDSA private key are stored as plaintext in the application-owned `web_sec/material` NVS blob, the SSH ECDSA private key is plaintext in `ssh_sec/material`, and Wi-Fi credentials are plaintext in `wifi_app/config`. The reserved `nvs_key` partition does not activate NVS encryption. ESP-IDF 5.5 keeps active mbedTLS allocations in external PSRAM under this memory configuration; wolfSSL/wolfSSH allocations also prefer PSRAM with internal-memory fallback. Neither framework guarantees complete zeroization of released session allocations, and PSRAM encryption is not enabled. Do not treat the current firmware as resistant to physical flash or RAM extraction; NVS encryption, flash/PSRAM encryption, secure boot, protected OTA, secret-aware core-dump handling, and framework-level key zeroization belong to the later hardening phase.
|
||||||
|
|
||||||
|
### Phase 6 authenticated SSH serial transport
|
||||||
|
|
||||||
|
A wolfSSH server listens on TCP port 22 across whichever Wi-Fi interfaces are active. It uses the same `admin` username and 24-character password as HTTPS, but has a separate persistent ECDSA P-256 host key in `ssh_sec/material`. The SSH identity is deliberately independent of the HTTPS certificate key so certificate rotation does not unexpectedly invalidate SSH `known_hosts` entries.
|
||||||
|
|
||||||
|
The UART0 administration console provides:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ssh
|
||||||
|
ssh help
|
||||||
|
ssh status
|
||||||
|
ssh start|stop
|
||||||
|
ssh sessions
|
||||||
|
ssh disconnect <session-id>
|
||||||
|
ssh counters|clear-counters
|
||||||
|
ssh host-key info
|
||||||
|
ssh host-key rotate --force
|
||||||
|
ssh reset --force
|
||||||
|
```
|
||||||
|
|
||||||
|
`ssh` and `ssh help` print the same usage summary. `ssh host-key info` prints the OpenSSH-style `SHA256:...` fingerprint over the standard `ecdsa-sha2-nistp256` public-key wire blob. `ssh host-key rotate --force` replaces a valid key; `ssh reset --force` explicitly replaces missing, incompatible, or corrupt SSH material. Both operations persist immediately, stop active SSH sessions before changing identity, and restart the service when appropriate. After deliberate rotation, remove the old host entry on the client with `ssh-keygen -R <address>` and verify the new fingerprint before reconnecting.
|
||||||
|
|
||||||
|
Only password authentication and an interactive shell/PTY channel are accepted. Public-key user authentication, arbitrary `exec`, subsystem requests, SFTP, SCP, agent forwarding, and TCP forwarding are not provided. Authentication is bounded to three password attempts per connection and a 15-second handshake deadline. At most two total SSH sessions or in-progress handshakes are admitted at once. Two deliberately stalled clients can occupy both fixed slots until their deadlines, so these bounds limit resource consumption but do not prevent availability attacks. This is useful embedded admission control, not a substitute for network filtering, strong unique credentials, monitoring, or mature brute-force protection.
|
||||||
|
|
||||||
|
After authentication and shell negotiation, the channel becomes a binary-transparent serial byte stream. The firmware does not inject prompts, status messages, writer-control escapes, line handling, or terminal emulation into channel data. SSH framing and encryption terminate at the ESP32; bytes between the SSH channel and session broker are retained across partial nonblocking transfers.
|
||||||
|
|
||||||
|
Each admitted shell becomes a `SESSION_BROKER_CLIENT_SSH`. The first session opportunistically requests the writer lease; a later session remains an observer if USB, WebSocket, a test client, or another SSH session already owns it. Observers receive UART output but their input is discarded. SSH deliberately has no in-band writer-control sequence because any reserved byte sequence would break binary transparency. Inspect ownership with `ssh sessions` or `broker clients`, and use the physical UART0 `broker force-writer <client-id|none>` recovery command when administrative reassignment is required.
|
||||||
|
|
||||||
|
Before connecting for the first time:
|
||||||
|
|
||||||
|
1. Run `web credentials show` on UART0 and keep the password private.
|
||||||
|
2. Run `ssh host-key info` and record the exact `SHA256:...` fingerprint.
|
||||||
|
3. Obtain the AP or station address from `wifi status`.
|
||||||
|
|
||||||
|
Then connect with OpenSSH, forcing the implemented authentication method if the host has unrelated keys configured:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no admin@192.168.4.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Compare OpenSSH's first-connection ECDSA fingerprint with `ssh host-key info` before accepting it. Once connected, all terminal bytes go directly to the physical RS-232 session. OpenSSH may print a one-time pre-authentication device banner; this is SSH protocol metadata and is not inserted into the serial stream.
|
||||||
|
|
||||||
|
The wolfSSH runtime has one owner task and two fixed session slots. A single owner serializes the managed component's `SINGLE_THREADED` build, avoids one large crypto stack per client, and services both nonblocking sessions fairly. The task has a 20 KiB internal stack; wolfSSL/wolfSSH dynamic allocations prefer external PSRAM and fall back to internal 8-bit memory. WolfCrypt's seed callback is installed after `wolfSSH_Init()` and routes all SSH RNG seeding through the project's serialized pre-radio CTR_DRBG instead of the registry component's default post-radio `esp_random()` path. HTTPS remains on ESP-IDF mbedTLS and is not switched to wolfSSL.
|
||||||
|
|
||||||
|
The official Espressif registry baseline is pinned in `src/idf_component.yml` and `dependencies.lock`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
wolfssl/wolfssl 5.8.2~1
|
||||||
|
wolfssl/wolfssh 1.4.20
|
||||||
|
```
|
||||||
|
|
||||||
|
The build enables `CONFIG_ESP_ENABLE_WOLFSSH`, keeps `CONFIG_ESP_TLS_USING_MBEDTLS`, and raises `CONFIG_LWIP_MAX_SOCKETS` to 16 for HTTPS/WebSocket and SSH concurrency. Adding the linked SSH implementation increased the measured release image from approximately 29.3% to 33.9% of the 4 MiB OTA slot and static RAM from 17.5% to 18.5% of 320 KiB. Runtime task stacks, Wi-Fi/TLS state, and PSRAM allocations are not fully represented by the static RAM percentage.
|
||||||
|
|
||||||
|
#### SSH validation
|
||||||
|
|
||||||
|
1. Boot and confirm `ssh status` reports `running=yes`, TCP port 22, password authentication, and no sessions.
|
||||||
|
2. Compare the first OpenSSH host-key prompt exactly with `ssh host-key info`; disconnect if it differs.
|
||||||
|
3. Verify a wrong password fails and the shared password from `web credentials show` succeeds.
|
||||||
|
4. Exercise text, NUL-containing/binary payloads, UTF-8, ANSI color, cursor-control sequences, and a full-screen terminal application through an RS-232 loopback or peer.
|
||||||
|
5. Confirm `ssh sessions`, `broker clients`, `ssh counters`, and serial counters reflect traffic and cleanup.
|
||||||
|
6. Connect a second SSH client. It should authenticate as an observer, receive identical UART output, and be unable to inject input while the first owns the writer lease.
|
||||||
|
7. Test ownership against native USB and WebSocket clients, using UART0 `broker force-writer` for deterministic reassignment.
|
||||||
|
8. Run `ssh disconnect <session-id>` and verify the socket and broker client disappear without affecting another observer.
|
||||||
|
9. Leave an unauthenticated TCP/SSH client stalled and verify its slot closes after 15 seconds while an established session continues.
|
||||||
|
10. Run `web credentials rotate --force`; active SSH clients should disconnect and the old password should fail.
|
||||||
|
11. Run `ssh host-key rotate --force`, remove the old client entry, and verify the newly printed fingerprint persists across reboot.
|
||||||
|
12. Exercise `ssh stop`, `ssh start`, Wi-Fi reconnect, and reboot while confirming UART0/native USB recovery remains available and no stale SSH broker clients survive.
|
||||||
|
|
||||||
### Phase 0 diagnostics
|
### Phase 0 diagnostics
|
||||||
|
|
||||||
@@ -408,4 +476,4 @@ This hardware profile uses the onboard RGB LED on GPIO48. Official ESP32-S3-DevK
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the [GNU General Public License version 3 only](LICENSE) (`GPL-3.0-only`). This is compatible with using the GPLv3 releases of wolfSSL and wolfSSH later. Third-party components remain subject to their respective licenses.
|
This project is licensed under the [GNU General Public License version 3 only](LICENSE) (`GPL-3.0-only`). Phase 6 pins the official Espressif registry components `wolfssl/wolfssl` 5.8.2~1 and `wolfssl/wolfssh` 1.4.20. Their GPL releases are compatible with this project; third-party components remain subject to their respective licenses. The wolfSSH 1.4.20 managed archive contains stale GPLv2-or-later wording in `LICENSE.txt` while upstream wolfSSH source headers identify GPLv3-or-later. Both are GPLv3-compatible, but distributors should preserve the upstream notices and re-check the registry package's license metadata. These registry versions are an integration baseline rather than a permanent security pin and must be reviewed against current upstream wolfSSL/wolfSSH releases during production hardening.
|
||||||
|
|||||||
+17
-1
@@ -49,10 +49,26 @@ dependencies:
|
|||||||
source:
|
source:
|
||||||
type: idf
|
type: idf
|
||||||
version: 5.5.0
|
version: 5.5.0
|
||||||
|
wolfssl/wolfssh:
|
||||||
|
component_hash: 24d623360c07374a90b1ade8d1218b24bb7b661fd51ace9135dccf510a338927
|
||||||
|
dependencies: []
|
||||||
|
source:
|
||||||
|
registry_url: https://components.espressif.com/
|
||||||
|
type: service
|
||||||
|
version: 1.4.20
|
||||||
|
wolfssl/wolfssl:
|
||||||
|
component_hash: 4d619e882c19d967bbaa53302e3bd2bdb8c611b5efb13302171aace809fccfdf
|
||||||
|
dependencies: []
|
||||||
|
source:
|
||||||
|
registry_url: https://components.espressif.com/
|
||||||
|
type: service
|
||||||
|
version: 5.8.2~1
|
||||||
direct_dependencies:
|
direct_dependencies:
|
||||||
- espressif/esp_tinyusb
|
- espressif/esp_tinyusb
|
||||||
- espressif/led_strip
|
- espressif/led_strip
|
||||||
- idf
|
- idf
|
||||||
manifest_hash: c088ae17e0ad9f6ac683daf40ffe6018e98fa32aea77d8f2252b0b2703e77298
|
- wolfssl/wolfssh
|
||||||
|
- wolfssl/wolfssl
|
||||||
|
manifest_hash: 34e93560f67a22e2a43647a4663b450e07ecd9fa3fdc2ac38343c1a76b513f5d
|
||||||
target: esp32s3
|
target: esp32s3
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ CONFIG_SPIRAM_USE_CAPS_ALLOC=y
|
|||||||
# Active TLS material remains unencrypted in PSRAM until the hardening phase.
|
# Active TLS material remains unencrypted in PSRAM until the hardening phase.
|
||||||
CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y
|
CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y
|
||||||
# CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set
|
# CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set
|
||||||
|
# HTTPS remains on ESP-IDF's mbedTLS backend; wolfSSL is linked only for wolfSSH.
|
||||||
|
CONFIG_ESP_TLS_USING_MBEDTLS=y
|
||||||
|
|
||||||
# Native USB OTG presents one CDC-ACM interface on the ESP32-S3 USB port.
|
# Native USB OTG presents one CDC-ACM interface on the ESP32-S3 USB port.
|
||||||
CONFIG_TINYUSB_CDC_ENABLED=y
|
CONFIG_TINYUSB_CDC_ENABLED=y
|
||||||
@@ -21,12 +23,19 @@ CONFIG_TINYUSB_CDC_EP_BUFSIZE=512
|
|||||||
# Enable the TLS-only administration server; no plaintext HTTP listener is created.
|
# Enable the TLS-only administration server; no plaintext HTTP listener is created.
|
||||||
CONFIG_ESP_HTTPS_SERVER_ENABLE=y
|
CONFIG_ESP_HTTPS_SERVER_ENABLE=y
|
||||||
CONFIG_HTTPD_WS_SUPPORT=y
|
CONFIG_HTTPD_WS_SUPPORT=y
|
||||||
|
# Reserve capacity for HTTPS/WebSocket clients plus two bounded SSH sessions.
|
||||||
|
CONFIG_LWIP_MAX_SOCKETS=16
|
||||||
# Keep work submission bounded; one-second socket timeouts limit shared-task stalls.
|
# Keep work submission bounded; one-second socket timeouts limit shared-task stalls.
|
||||||
# CONFIG_HTTPD_QUEUE_WORK_BLOCKING is not set
|
# CONFIG_HTTPD_QUEUE_WORK_BLOCKING is not set
|
||||||
|
|
||||||
# Certificate generation and HTTPS startup use nested cryptographic buffers.
|
# Certificate generation and HTTPS startup use nested cryptographic buffers.
|
||||||
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
|
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
|
||||||
|
|
||||||
|
# Build wolfSSH and wolfCrypt without replacing the HTTPS TLS implementation.
|
||||||
|
CONFIG_ESP_ENABLE_WOLFSSH=y
|
||||||
|
# The managed component emits a generic RSA stack warning although this target disables RSA.
|
||||||
|
CONFIG_ESP_WOLFSSL_NO_STACK_SIZE_BUILD_WARNING=y
|
||||||
|
|
||||||
# Support WPA3-SAE for station profiles and the WPA2/WPA3 fallback AP.
|
# Support WPA3-SAE for station profiles and the WPA2/WPA3 fallback AP.
|
||||||
CONFIG_ESP_WIFI_ENABLE_WPA3_SAE=y
|
CONFIG_ESP_WIFI_ENABLE_WPA3_SAE=y
|
||||||
CONFIG_ESP_WIFI_ENABLE_SAE_H2E=y
|
CONFIG_ESP_WIFI_ENABLE_SAE_H2E=y
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ idf_component_register(
|
|||||||
"serial_console.c"
|
"serial_console.c"
|
||||||
"session_broker.c"
|
"session_broker.c"
|
||||||
"session_console.c"
|
"session_console.c"
|
||||||
|
"ssh_security.c"
|
||||||
|
"ssh_transport.c"
|
||||||
|
"ssh_console.c"
|
||||||
"usb_cdc_transport.c"
|
"usb_cdc_transport.c"
|
||||||
"usb_console.c"
|
"usb_console.c"
|
||||||
"web_security.c"
|
"web_security.c"
|
||||||
@@ -44,4 +47,13 @@ idf_component_register(
|
|||||||
lwip
|
lwip
|
||||||
mbedtls
|
mbedtls
|
||||||
nvs_flash
|
nvs_flash
|
||||||
|
wolfssl__wolfssh
|
||||||
|
wolfssl__wolfssl
|
||||||
|
)
|
||||||
|
|
||||||
|
# Public wolfSSH headers include wolfCrypt configuration from user_settings.h.
|
||||||
|
target_compile_definitions(${COMPONENT_LIB} PRIVATE
|
||||||
|
WOLFSSL_USER_SETTINGS
|
||||||
|
WOLFSSH_USER_SETTINGS
|
||||||
|
WC_RNG_SEED_CB
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -126,6 +126,22 @@ static const char *const s_completion_candidates[] = {
|
|||||||
"web certificate rotate --force",
|
"web certificate rotate --force",
|
||||||
"web reset",
|
"web reset",
|
||||||
"web reset --force",
|
"web reset --force",
|
||||||
|
|
||||||
|
/* Authenticated SSH serial transport and independent host identity. */
|
||||||
|
"ssh help",
|
||||||
|
"ssh status",
|
||||||
|
"ssh start",
|
||||||
|
"ssh stop",
|
||||||
|
"ssh sessions",
|
||||||
|
"ssh disconnect",
|
||||||
|
"ssh counters",
|
||||||
|
"ssh clear-counters",
|
||||||
|
"ssh host-key",
|
||||||
|
"ssh host-key info",
|
||||||
|
"ssh host-key rotate",
|
||||||
|
"ssh host-key rotate --force",
|
||||||
|
"ssh reset",
|
||||||
|
"ssh reset --force",
|
||||||
};
|
};
|
||||||
|
|
||||||
static ssize_t console_read_with_late_terminal_upgrade(int file_descriptor,
|
static ssize_t console_read_with_late_terminal_upgrade(int file_descriptor,
|
||||||
|
|||||||
@@ -4,3 +4,6 @@ dependencies:
|
|||||||
idf: ">=5.3.0"
|
idf: ">=5.3.0"
|
||||||
espressif/led_strip: "^3.0.3"
|
espressif/led_strip: "^3.0.3"
|
||||||
espressif/esp_tinyusb: "^2.2.1"
|
espressif/esp_tinyusb: "^2.2.1"
|
||||||
|
# Exact official registry versions form the reviewed Phase 6 integration baseline.
|
||||||
|
wolfssl/wolfssl: "5.8.2~1"
|
||||||
|
wolfssl/wolfssh: "1.4.20"
|
||||||
|
|||||||
+38
-1
@@ -13,6 +13,9 @@
|
|||||||
#include "serial_service.h"
|
#include "serial_service.h"
|
||||||
#include "session_broker.h"
|
#include "session_broker.h"
|
||||||
#include "session_console.h"
|
#include "session_console.h"
|
||||||
|
#include "ssh_console.h"
|
||||||
|
#include "ssh_security.h"
|
||||||
|
#include "ssh_transport.h"
|
||||||
#include "status_led.h"
|
#include "status_led.h"
|
||||||
#include "system_console.h"
|
#include "system_console.h"
|
||||||
#include "usb_cdc_transport.h"
|
#include "usb_cdc_transport.h"
|
||||||
@@ -32,7 +35,7 @@ static const char *TAG = "firmware";
|
|||||||
|
|
||||||
void app_main(void)
|
void app_main(void)
|
||||||
{
|
{
|
||||||
ESP_LOGI(TAG, "ESP32-S3 Serial Swiss Army Knife HTTPS foundation phase started");
|
ESP_LOGI(TAG, "ESP32-S3 Serial Swiss Army Knife SSH transport phase started");
|
||||||
|
|
||||||
if (esp_psram_is_initialized()) {
|
if (esp_psram_is_initialized()) {
|
||||||
ESP_LOGI(TAG, "PSRAM initialized: %u bytes", (unsigned int)esp_psram_get_size());
|
ESP_LOGI(TAG, "PSRAM initialized: %u bytes", (unsigned int)esp_psram_get_size());
|
||||||
@@ -88,6 +91,28 @@ void app_main(void)
|
|||||||
esp_err_to_name(web_runtime_error));
|
esp_err_to_name(web_runtime_error));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ssh_security_load_result_t ssh_security_source = SSH_SECURITY_LOAD_STORED;
|
||||||
|
esp_err_t ssh_security_error = random_error;
|
||||||
|
if (ssh_security_error == ESP_OK) {
|
||||||
|
ssh_security_error = ssh_security_init(&ssh_security_source);
|
||||||
|
}
|
||||||
|
if (ssh_security_error != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG,
|
||||||
|
"SSH host key unavailable (%s); use UART0 'ssh reset --force' to replace it",
|
||||||
|
esp_err_to_name(ssh_security_error));
|
||||||
|
} else {
|
||||||
|
ESP_LOGI(TAG, "Using %s SSH host key",
|
||||||
|
ssh_security_source == SSH_SECURITY_LOAD_STORED
|
||||||
|
? "stored"
|
||||||
|
: "newly generated");
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t ssh_runtime_error = ssh_transport_init();
|
||||||
|
if (ssh_runtime_error != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "SSH runtime initialization failed: %s",
|
||||||
|
esp_err_to_name(ssh_runtime_error));
|
||||||
|
}
|
||||||
|
|
||||||
wifi_app_config_t wifi_config;
|
wifi_app_config_t wifi_config;
|
||||||
wifi_config_load_source_t wifi_config_source;
|
wifi_config_load_source_t wifi_config_source;
|
||||||
esp_err_t wifi_config_error = random_error;
|
esp_err_t wifi_config_error = random_error;
|
||||||
@@ -143,6 +168,17 @@ void app_main(void)
|
|||||||
ESP_LOGI(TAG, "Authenticated HTTPS listening on TCP port 443");
|
ESP_LOGI(TAG, "Authenticated HTTPS listening on TCP port 443");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (wifi_error == ESP_OK && web_security_error == ESP_OK &&
|
||||||
|
ssh_security_error == ESP_OK && ssh_runtime_error == ESP_OK) {
|
||||||
|
esp_err_t start_error = ssh_transport_start();
|
||||||
|
if (start_error != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "SSH startup failed: %s; UART0 recovery remains available",
|
||||||
|
esp_err_to_name(start_error));
|
||||||
|
} else {
|
||||||
|
ESP_LOGI(TAG, "Authenticated SSH listening on TCP port %u",
|
||||||
|
SSH_TRANSPORT_PORT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ESP_LOGI(
|
ESP_LOGI(
|
||||||
TAG,
|
TAG,
|
||||||
@@ -174,6 +210,7 @@ void app_main(void)
|
|||||||
ESP_ERROR_CHECK(usb_console_register_commands());
|
ESP_ERROR_CHECK(usb_console_register_commands());
|
||||||
ESP_ERROR_CHECK(wifi_console_register_commands());
|
ESP_ERROR_CHECK(wifi_console_register_commands());
|
||||||
ESP_ERROR_CHECK(web_console_register_commands());
|
ESP_ERROR_CHECK(web_console_register_commands());
|
||||||
|
ESP_ERROR_CHECK(ssh_console_register_commands());
|
||||||
ESP_ERROR_CHECK(network_console_register_root_commands());
|
ESP_ERROR_CHECK(network_console_register_root_commands());
|
||||||
ESP_ERROR_CHECK(system_console_register_commands());
|
ESP_ERROR_CHECK(system_console_register_commands());
|
||||||
/* Upgrade late UART terminals safely and add nested completion. */
|
/* Upgrade late UART terminals safely and add nested completion. */
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||||
|
/* UART0 SSH lifecycle, sessions, counters, and host-key recovery commands. */
|
||||||
|
|
||||||
|
#include "ssh_console.h"
|
||||||
|
|
||||||
|
#include <errno.h>
|
||||||
|
#include <inttypes.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "esp_console.h"
|
||||||
|
#include "mbedtls/base64.h"
|
||||||
|
#include "secure_random.h"
|
||||||
|
#include "ssh_security.h"
|
||||||
|
#include "ssh_transport.h"
|
||||||
|
#include "web_security.h"
|
||||||
|
|
||||||
|
static void print_usage(void)
|
||||||
|
{
|
||||||
|
printf("Usage:\n");
|
||||||
|
printf(" ssh status|start|stop|sessions\n");
|
||||||
|
printf(" ssh disconnect <session-id>\n");
|
||||||
|
printf(" ssh counters|clear-counters\n");
|
||||||
|
printf(" ssh host-key info\n");
|
||||||
|
printf(" ssh host-key rotate --force\n");
|
||||||
|
printf(" ssh reset --force\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char *state_name(ssh_transport_session_state_t state)
|
||||||
|
{
|
||||||
|
switch (state) {
|
||||||
|
case SSH_TRANSPORT_SESSION_FREE:
|
||||||
|
return "free";
|
||||||
|
case SSH_TRANSPORT_SESSION_HANDSHAKE:
|
||||||
|
return "handshake";
|
||||||
|
case SSH_TRANSPORT_SESSION_ACTIVE:
|
||||||
|
return "active";
|
||||||
|
case SSH_TRANSPORT_SESSION_CLOSING:
|
||||||
|
return "closing";
|
||||||
|
default:
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int print_sessions(const ssh_transport_snapshot_t *snapshot)
|
||||||
|
{
|
||||||
|
printf("SSH sessions: active=%" PRIu32 "/%u\n",
|
||||||
|
snapshot->active_sessions, SSH_TRANSPORT_MAX_SESSIONS);
|
||||||
|
for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) {
|
||||||
|
const ssh_transport_session_snapshot_t *session = &snapshot->sessions[index];
|
||||||
|
if (!session->active) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
printf(" id=%" PRIu32 " slot=%u peer=%s state=%s auth=%s broker=%" PRIu32
|
||||||
|
" role=%s rx-pending=%s tx-pending=%s closing=%s\n",
|
||||||
|
session->session_id, (unsigned int)index, session->peer,
|
||||||
|
state_name(session->state), session->authenticated ? "yes" : "no",
|
||||||
|
session->broker_client_id,
|
||||||
|
session->broker_client_id == SESSION_BROKER_NO_CLIENT
|
||||||
|
? "unattached"
|
||||||
|
: (session->writer ? "writer" : "observer"),
|
||||||
|
session->rx_pending ? "yes" : "no",
|
||||||
|
session->tx_pending ? "yes" : "no",
|
||||||
|
session->close_requested ? "yes" : "no");
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int show_status(bool sessions_only)
|
||||||
|
{
|
||||||
|
ssh_transport_snapshot_t snapshot;
|
||||||
|
esp_err_t error = ssh_transport_get_snapshot(&snapshot);
|
||||||
|
if (error != ESP_OK) {
|
||||||
|
printf("SSH runtime unavailable: %s\n", esp_err_to_name(error));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
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",
|
||||||
|
snapshot.initialized ? "yes" : "no",
|
||||||
|
snapshot.running ? "yes" : "no",
|
||||||
|
snapshot.transitioning ? "yes" : "no",
|
||||||
|
(unsigned int)snapshot.port,
|
||||||
|
esp_err_to_name(snapshot.last_error));
|
||||||
|
if (username_error == ESP_OK) {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
return print_sessions(&snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int show_counters(void)
|
||||||
|
{
|
||||||
|
ssh_transport_snapshot_t snapshot;
|
||||||
|
esp_err_t error = ssh_transport_get_snapshot(&snapshot);
|
||||||
|
if (error != ESP_OK) {
|
||||||
|
printf("Could not read SSH counters: %s\n", esp_err_to_name(error));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const ssh_transport_counters_t *counter = &snapshot.counters;
|
||||||
|
printf("Lifecycle: starts=%" PRIu64 " start-failures=%" PRIu64
|
||||||
|
" stops=%" PRIu64 " tcp-connect=%" PRIu64
|
||||||
|
" capacity-reject=%" PRIu64 "\n",
|
||||||
|
counter->starts, counter->start_failures, counter->stops,
|
||||||
|
counter->tcp_connections, counter->capacity_rejections);
|
||||||
|
printf("Handshake: success=%" PRIu64 " failures=%" PRIu64
|
||||||
|
" timeouts=%" PRIu64 " auth-attempts=%" PRIu64
|
||||||
|
" auth-failures=%" PRIu64 " request-rejects=%" PRIu64 "\n",
|
||||||
|
counter->handshake_successes, counter->handshake_failures,
|
||||||
|
counter->handshake_timeouts, counter->authentication_attempts,
|
||||||
|
counter->authentication_failures, counter->request_rejections);
|
||||||
|
printf("Broker: connect=%" PRIu64 " failures=%" PRIu64
|
||||||
|
" disconnect=%" PRIu64 " writer-requests=%" PRIu64
|
||||||
|
" grants=%" PRIu64 " denials=%" PRIu64
|
||||||
|
" revocations=%" PRIu64 "\n",
|
||||||
|
counter->broker_connections, counter->broker_failures,
|
||||||
|
counter->disconnections, counter->writer_requests,
|
||||||
|
counter->writer_grants, counter->writer_denials,
|
||||||
|
counter->writer_revocations);
|
||||||
|
printf("Stream: rx=%" PRIu64 " accepted=%" PRIu64
|
||||||
|
" rejected=%" PRIu64 " tx=%" PRIu64
|
||||||
|
" io-failures=%" PRIu64 " session-revocations=%" PRIu64 "\n",
|
||||||
|
counter->rx_bytes, counter->rx_accepted_bytes,
|
||||||
|
counter->rx_rejected_bytes, counter->tx_bytes,
|
||||||
|
counter->io_failures, counter->session_revocations);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int show_host_key(void)
|
||||||
|
{
|
||||||
|
ssh_security_metadata_t metadata;
|
||||||
|
esp_err_t error = ssh_security_get_metadata(&metadata);
|
||||||
|
if (error != ESP_OK) {
|
||||||
|
printf("Could not read SSH host-key information: %s\n", esp_err_to_name(error));
|
||||||
|
printf("Use 'ssh reset --force' to replace incompatible or corrupt material.\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned char encoded[48] = {0};
|
||||||
|
size_t encoded_length = 0U;
|
||||||
|
int result = mbedtls_base64_encode(encoded, sizeof(encoded), &encoded_length,
|
||||||
|
metadata.sha256_fingerprint,
|
||||||
|
sizeof(metadata.sha256_fingerprint));
|
||||||
|
if (result != 0 || encoded_length >= sizeof(encoded)) {
|
||||||
|
secure_wipe(encoded, sizeof(encoded));
|
||||||
|
printf("Could not encode SSH host-key fingerprint.\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
while (encoded_length > 0U && encoded[encoded_length - 1U] == '=') {
|
||||||
|
--encoded_length;
|
||||||
|
}
|
||||||
|
encoded[encoded_length] = '\0';
|
||||||
|
printf("SSH host key: generation=%" PRIu32 " type=%s curve=%s\n",
|
||||||
|
metadata.generation, SSH_SECURITY_KEY_TYPE, SSH_SECURITY_CURVE_NAME);
|
||||||
|
printf("OpenSSH SHA-256 fingerprint: SHA256:%s\n", encoded);
|
||||||
|
secure_wipe(encoded, sizeof(encoded));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool parse_session_id(const char *text, uint32_t *session_id)
|
||||||
|
{
|
||||||
|
if (text == NULL || text[0] == '\0' || session_id == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
errno = 0;
|
||||||
|
char *end = NULL;
|
||||||
|
unsigned long value = strtoul(text, &end, 10);
|
||||||
|
if (errno != 0 || end == text || *end != '\0' || value == 0UL ||
|
||||||
|
value > UINT32_MAX) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*session_id = (uint32_t)value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int replace_host_key(bool reset)
|
||||||
|
{
|
||||||
|
ssh_security_metadata_t before = {0};
|
||||||
|
bool had_before = ssh_security_get_metadata(&before) == ESP_OK;
|
||||||
|
esp_err_t error = ssh_transport_replace_host_key(reset);
|
||||||
|
|
||||||
|
ssh_security_metadata_t after = {0};
|
||||||
|
bool have_after = ssh_security_get_metadata(&after) == ESP_OK;
|
||||||
|
bool replaced = have_after && (!had_before || after.generation != before.generation);
|
||||||
|
if (error != ESP_OK) {
|
||||||
|
if (replaced) {
|
||||||
|
printf("SSH host key was persisted, but the transport could not complete its restart: %s\n",
|
||||||
|
esp_err_to_name(error));
|
||||||
|
} else {
|
||||||
|
printf("Could not %s SSH host key: %s\n",
|
||||||
|
reset ? "reset" : "rotate", esp_err_to_name(error));
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("SSH host key replaced and persisted; existing clients must verify the new fingerprint.\n");
|
||||||
|
return show_host_key();
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool force_is_present(int argc, char **argv, int expected_argc)
|
||||||
|
{
|
||||||
|
return argc == expected_argc && strcmp(argv[expected_argc - 1], "--force") == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int command_ssh(int argc, char **argv)
|
||||||
|
{
|
||||||
|
if (argc == 1 || (argc == 2 && strcmp(argv[1], "help") == 0)) {
|
||||||
|
print_usage();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (argc == 2 && strcmp(argv[1], "status") == 0) {
|
||||||
|
return show_status(false);
|
||||||
|
}
|
||||||
|
if (argc == 2 && strcmp(argv[1], "sessions") == 0) {
|
||||||
|
return show_status(true);
|
||||||
|
}
|
||||||
|
if (argc == 2 && strcmp(argv[1], "start") == 0) {
|
||||||
|
esp_err_t error = ssh_transport_start();
|
||||||
|
if (error != ESP_OK) {
|
||||||
|
printf("Could not start SSH: %s\n", esp_err_to_name(error));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printf("SSH started on TCP port %u.\n", SSH_TRANSPORT_PORT);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (argc == 2 && strcmp(argv[1], "stop") == 0) {
|
||||||
|
esp_err_t error = ssh_transport_stop();
|
||||||
|
if (error != ESP_OK) {
|
||||||
|
printf("Could not stop SSH: %s\n", esp_err_to_name(error));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printf("SSH stopped.\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (argc == 2 && strcmp(argv[1], "counters") == 0) {
|
||||||
|
return show_counters();
|
||||||
|
}
|
||||||
|
if (argc == 2 && strcmp(argv[1], "clear-counters") == 0) {
|
||||||
|
esp_err_t error = ssh_transport_clear_counters();
|
||||||
|
if (error != ESP_OK) {
|
||||||
|
printf("Could not clear SSH counters: %s\n", esp_err_to_name(error));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printf("SSH counters cleared.\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (argc == 3 && strcmp(argv[1], "disconnect") == 0) {
|
||||||
|
uint32_t session_id = 0U;
|
||||||
|
if (!parse_session_id(argv[2], &session_id)) {
|
||||||
|
printf("Session ID must be a nonzero decimal integer.\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
esp_err_t error = ssh_transport_disconnect(session_id);
|
||||||
|
if (error != ESP_OK) {
|
||||||
|
printf("Could not disconnect SSH session: %s\n", esp_err_to_name(error));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printf("SSH session %" PRIu32 " scheduled for disconnect.\n", session_id);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (argc == 3 && strcmp(argv[1], "host-key") == 0 &&
|
||||||
|
strcmp(argv[2], "info") == 0) {
|
||||||
|
return show_host_key();
|
||||||
|
}
|
||||||
|
if (argc >= 3 && strcmp(argv[1], "host-key") == 0 &&
|
||||||
|
strcmp(argv[2], "rotate") == 0) {
|
||||||
|
if (!force_is_present(argc, argv, 4)) {
|
||||||
|
printf("Host-key rotation requires: ssh host-key rotate --force\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return replace_host_key(false);
|
||||||
|
}
|
||||||
|
if (strcmp(argv[1], "reset") == 0) {
|
||||||
|
if (!force_is_present(argc, argv, 3)) {
|
||||||
|
printf("Host-key recovery requires: ssh reset --force\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return replace_host_key(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
print_usage();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t ssh_console_register_commands(void)
|
||||||
|
{
|
||||||
|
const esp_console_cmd_t command = {
|
||||||
|
.command = "ssh",
|
||||||
|
.help = "Manage authenticated SSH serial transport and host identity",
|
||||||
|
.hint = NULL,
|
||||||
|
.func = &command_ssh,
|
||||||
|
.argtable = NULL,
|
||||||
|
};
|
||||||
|
return esp_console_cmd_register(&command);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||||
|
/* UART0 administration commands for the SSH serial transport. */
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "esp_err.h"
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
esp_err_t ssh_console_register_commands(void);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||||
|
/* Versioned NVS storage and validation for the SSH ECDSA P-256 host key. */
|
||||||
|
|
||||||
|
#include "ssh_security.h"
|
||||||
|
|
||||||
|
#include <limits.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/semphr.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
#include "mbedtls/ecp.h"
|
||||||
|
#include "mbedtls/pk.h"
|
||||||
|
#include "mbedtls/sha256.h"
|
||||||
|
#include "nvs.h"
|
||||||
|
#include "secure_random.h"
|
||||||
|
|
||||||
|
#define SSH_SECURITY_SCHEMA_VERSION 1U
|
||||||
|
#define SSH_SECURITY_BLOB_SIZE 312U
|
||||||
|
#define SSH_PUBLIC_POINT_LENGTH 65U
|
||||||
|
#define SSH_PUBLIC_BLOB_LENGTH \
|
||||||
|
(4U + (sizeof(SSH_SECURITY_KEY_TYPE) - 1U) + \
|
||||||
|
4U + (sizeof(SSH_SECURITY_CURVE_NAME) - 1U) + \
|
||||||
|
4U + SSH_PUBLIC_POINT_LENGTH)
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint32_t schema_version;
|
||||||
|
uint16_t blob_size;
|
||||||
|
uint16_t private_key_length;
|
||||||
|
uint32_t generation;
|
||||||
|
uint8_t private_key_der[SSH_SECURITY_PRIVATE_KEY_DER_CAPACITY];
|
||||||
|
uint8_t sha256_fingerprint[SSH_SECURITY_SHA256_LENGTH];
|
||||||
|
uint8_t reserved[12];
|
||||||
|
} ssh_security_blob_t;
|
||||||
|
|
||||||
|
_Static_assert(offsetof(ssh_security_blob_t, private_key_der) == 12U,
|
||||||
|
"SSH security schema offsets changed");
|
||||||
|
_Static_assert(offsetof(ssh_security_blob_t, sha256_fingerprint) == 268U,
|
||||||
|
"SSH fingerprint offset changed");
|
||||||
|
_Static_assert(sizeof(ssh_security_blob_t) == SSH_SECURITY_BLOB_SIZE,
|
||||||
|
"SSH security schema size changed");
|
||||||
|
|
||||||
|
static SemaphoreHandle_t s_security_mutex;
|
||||||
|
static portMUX_TYPE s_mutex_init_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||||
|
static bool s_mutex_creating;
|
||||||
|
static ssh_security_blob_t s_material;
|
||||||
|
static bool s_material_ready;
|
||||||
|
static ssh_security_load_result_t s_load_result;
|
||||||
|
|
||||||
|
static bool bytes_are_zero(const uint8_t *data, size_t size)
|
||||||
|
{
|
||||||
|
for (size_t index = 0U; index < size; ++index) {
|
||||||
|
if (data[index] != 0U) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool constant_time_equal(const uint8_t *left, const uint8_t *right,
|
||||||
|
size_t size)
|
||||||
|
{
|
||||||
|
uint8_t difference = 0U;
|
||||||
|
for (size_t index = 0U; index < size; ++index) {
|
||||||
|
difference |= left[index] ^ right[index];
|
||||||
|
}
|
||||||
|
return difference == 0U;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t ensure_mutex(void)
|
||||||
|
{
|
||||||
|
for (;;) {
|
||||||
|
bool create = false;
|
||||||
|
taskENTER_CRITICAL(&s_mutex_init_lock);
|
||||||
|
if (s_security_mutex != NULL) {
|
||||||
|
taskEXIT_CRITICAL(&s_mutex_init_lock);
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
if (!s_mutex_creating) {
|
||||||
|
s_mutex_creating = true;
|
||||||
|
create = true;
|
||||||
|
}
|
||||||
|
taskEXIT_CRITICAL(&s_mutex_init_lock);
|
||||||
|
|
||||||
|
if (create) {
|
||||||
|
SemaphoreHandle_t mutex = xSemaphoreCreateMutex();
|
||||||
|
taskENTER_CRITICAL(&s_mutex_init_lock);
|
||||||
|
s_security_mutex = mutex;
|
||||||
|
s_mutex_creating = false;
|
||||||
|
taskEXIT_CRITICAL(&s_mutex_init_lock);
|
||||||
|
return mutex != NULL ? ESP_OK : ESP_ERR_NO_MEM;
|
||||||
|
}
|
||||||
|
vTaskDelay(1U);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void write_u32_be(uint8_t output[4], uint32_t value)
|
||||||
|
{
|
||||||
|
output[0] = (uint8_t)(value >> 24U);
|
||||||
|
output[1] = (uint8_t)(value >> 16U);
|
||||||
|
output[2] = (uint8_t)(value >> 8U);
|
||||||
|
output[3] = (uint8_t)value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static size_t append_ssh_string(uint8_t *output, size_t offset,
|
||||||
|
const uint8_t *value, size_t value_length)
|
||||||
|
{
|
||||||
|
write_u32_be(output + offset, (uint32_t)value_length);
|
||||||
|
offset += 4U;
|
||||||
|
memcpy(output + offset, value, value_length);
|
||||||
|
return offset + value_length;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t fingerprint_key(const mbedtls_pk_context *key,
|
||||||
|
uint8_t fingerprint[SSH_SECURITY_SHA256_LENGTH])
|
||||||
|
{
|
||||||
|
const mbedtls_ecp_keypair *ec = mbedtls_pk_ec(*key);
|
||||||
|
if (ec == NULL ||
|
||||||
|
mbedtls_ecp_keypair_get_group_id(ec) != MBEDTLS_ECP_DP_SECP256R1) {
|
||||||
|
return ESP_ERR_INVALID_RESPONSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t point[SSH_PUBLIC_POINT_LENGTH] = {0};
|
||||||
|
size_t point_length = 0U;
|
||||||
|
int result = mbedtls_ecp_point_write_binary(
|
||||||
|
&ec->MBEDTLS_PRIVATE(grp), &ec->MBEDTLS_PRIVATE(Q),
|
||||||
|
MBEDTLS_ECP_PF_UNCOMPRESSED,
|
||||||
|
&point_length, point, sizeof(point));
|
||||||
|
if (result != 0 || point_length != sizeof(point)) {
|
||||||
|
secure_wipe(point, sizeof(point));
|
||||||
|
return ESP_ERR_INVALID_RESPONSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t public_blob[SSH_PUBLIC_BLOB_LENGTH] = {0};
|
||||||
|
size_t offset = append_ssh_string(
|
||||||
|
public_blob, 0U, (const uint8_t *)SSH_SECURITY_KEY_TYPE,
|
||||||
|
sizeof(SSH_SECURITY_KEY_TYPE) - 1U);
|
||||||
|
offset = append_ssh_string(
|
||||||
|
public_blob, offset, (const uint8_t *)SSH_SECURITY_CURVE_NAME,
|
||||||
|
sizeof(SSH_SECURITY_CURVE_NAME) - 1U);
|
||||||
|
offset = append_ssh_string(public_blob, offset, point, point_length);
|
||||||
|
|
||||||
|
result = offset == sizeof(public_blob)
|
||||||
|
? mbedtls_sha256(public_blob, offset, fingerprint, 0)
|
||||||
|
: -1;
|
||||||
|
secure_wipe(public_blob, sizeof(public_blob));
|
||||||
|
secure_wipe(point, sizeof(point));
|
||||||
|
return result == 0 ? ESP_OK : ESP_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t parse_and_fingerprint(const ssh_security_blob_t *blob,
|
||||||
|
uint8_t fingerprint[SSH_SECURITY_SHA256_LENGTH])
|
||||||
|
{
|
||||||
|
mbedtls_pk_context key;
|
||||||
|
mbedtls_pk_init(&key);
|
||||||
|
int result = mbedtls_pk_parse_key(&key,
|
||||||
|
blob->private_key_der,
|
||||||
|
blob->private_key_length,
|
||||||
|
NULL, 0U,
|
||||||
|
secure_random_mbedtls, NULL);
|
||||||
|
esp_err_t error = ESP_ERR_INVALID_RESPONSE;
|
||||||
|
if (result == 0 && mbedtls_pk_get_type(&key) == MBEDTLS_PK_ECKEY) {
|
||||||
|
const mbedtls_ecp_keypair *ec = mbedtls_pk_ec(key);
|
||||||
|
if (ec != NULL &&
|
||||||
|
mbedtls_ecp_keypair_get_group_id(ec) == MBEDTLS_ECP_DP_SECP256R1 &&
|
||||||
|
mbedtls_ecp_check_privkey(&ec->MBEDTLS_PRIVATE(grp),
|
||||||
|
&ec->MBEDTLS_PRIVATE(d)) == 0 &&
|
||||||
|
mbedtls_ecp_check_pubkey(&ec->MBEDTLS_PRIVATE(grp),
|
||||||
|
&ec->MBEDTLS_PRIVATE(Q)) == 0 &&
|
||||||
|
mbedtls_pk_check_pair(&key, &key,
|
||||||
|
secure_random_mbedtls, NULL) == 0) {
|
||||||
|
error = fingerprint_key(&key, fingerprint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mbedtls_pk_free(&key);
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t validate_blob(const ssh_security_blob_t *blob)
|
||||||
|
{
|
||||||
|
if (blob == NULL) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
if (blob->schema_version != SSH_SECURITY_SCHEMA_VERSION ||
|
||||||
|
blob->blob_size != SSH_SECURITY_BLOB_SIZE) {
|
||||||
|
return ESP_ERR_INVALID_VERSION;
|
||||||
|
}
|
||||||
|
if (blob->generation == 0U || blob->private_key_length == 0U ||
|
||||||
|
blob->private_key_length > sizeof(blob->private_key_der) ||
|
||||||
|
!bytes_are_zero(blob->private_key_der + blob->private_key_length,
|
||||||
|
sizeof(blob->private_key_der) - blob->private_key_length) ||
|
||||||
|
!bytes_are_zero(blob->reserved, sizeof(blob->reserved))) {
|
||||||
|
return ESP_ERR_INVALID_RESPONSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t fingerprint[SSH_SECURITY_SHA256_LENGTH] = {0};
|
||||||
|
esp_err_t error = parse_and_fingerprint(blob, fingerprint);
|
||||||
|
if (error == ESP_OK &&
|
||||||
|
!constant_time_equal(fingerprint, blob->sha256_fingerprint,
|
||||||
|
sizeof(fingerprint))) {
|
||||||
|
error = ESP_ERR_INVALID_RESPONSE;
|
||||||
|
}
|
||||||
|
secure_wipe(fingerprint, sizeof(fingerprint));
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t generate_blob(ssh_security_blob_t *blob, uint32_t generation)
|
||||||
|
{
|
||||||
|
memset(blob, 0, sizeof(*blob));
|
||||||
|
blob->schema_version = SSH_SECURITY_SCHEMA_VERSION;
|
||||||
|
blob->blob_size = SSH_SECURITY_BLOB_SIZE;
|
||||||
|
blob->generation = generation;
|
||||||
|
|
||||||
|
mbedtls_pk_context key;
|
||||||
|
mbedtls_pk_init(&key);
|
||||||
|
int result = mbedtls_pk_setup(&key, mbedtls_pk_info_from_type(MBEDTLS_PK_ECKEY));
|
||||||
|
esp_err_t error = result == 0 ? ESP_OK : ESP_ERR_NO_MEM;
|
||||||
|
if (error == ESP_OK) {
|
||||||
|
result = mbedtls_ecp_gen_key(MBEDTLS_ECP_DP_SECP256R1,
|
||||||
|
mbedtls_pk_ec(key),
|
||||||
|
secure_random_mbedtls, NULL);
|
||||||
|
error = result == 0 ? ESP_OK : ESP_FAIL;
|
||||||
|
}
|
||||||
|
if (error == ESP_OK) {
|
||||||
|
result = mbedtls_pk_write_key_der(&key, blob->private_key_der,
|
||||||
|
sizeof(blob->private_key_der));
|
||||||
|
if (result <= 0 || (size_t)result > sizeof(blob->private_key_der)) {
|
||||||
|
error = ESP_FAIL;
|
||||||
|
} else {
|
||||||
|
size_t length = (size_t)result;
|
||||||
|
memmove(blob->private_key_der,
|
||||||
|
blob->private_key_der + sizeof(blob->private_key_der) - length,
|
||||||
|
length);
|
||||||
|
memset(blob->private_key_der + length, 0,
|
||||||
|
sizeof(blob->private_key_der) - length);
|
||||||
|
blob->private_key_length = (uint16_t)length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (error == ESP_OK) {
|
||||||
|
error = fingerprint_key(&key, blob->sha256_fingerprint);
|
||||||
|
}
|
||||||
|
mbedtls_pk_free(&key);
|
||||||
|
if (error == ESP_OK) {
|
||||||
|
error = validate_blob(blob);
|
||||||
|
}
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t save_blob(const ssh_security_blob_t *blob)
|
||||||
|
{
|
||||||
|
esp_err_t error = validate_blob(blob);
|
||||||
|
if (error != ESP_OK) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
nvs_handle_t handle;
|
||||||
|
error = nvs_open(SSH_SECURITY_NVS_NAMESPACE, NVS_READWRITE, &handle);
|
||||||
|
if (error != ESP_OK) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
error = nvs_set_blob(handle, SSH_SECURITY_NVS_BLOB_KEY, blob, sizeof(*blob));
|
||||||
|
if (error == ESP_OK) {
|
||||||
|
error = nvs_commit(handle);
|
||||||
|
}
|
||||||
|
nvs_close(handle);
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t load_blob(ssh_security_blob_t *blob, bool *missing)
|
||||||
|
{
|
||||||
|
*missing = false;
|
||||||
|
nvs_handle_t handle;
|
||||||
|
esp_err_t error = nvs_open(SSH_SECURITY_NVS_NAMESPACE, NVS_READONLY, &handle);
|
||||||
|
if (error == ESP_ERR_NVS_NOT_FOUND) {
|
||||||
|
*missing = true;
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
if (error != ESP_OK) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t size = 0U;
|
||||||
|
error = nvs_get_blob(handle, SSH_SECURITY_NVS_BLOB_KEY, NULL, &size);
|
||||||
|
if (error == ESP_ERR_NVS_NOT_FOUND) {
|
||||||
|
*missing = true;
|
||||||
|
nvs_close(handle);
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
if (error == ESP_ERR_NVS_TYPE_MISMATCH) {
|
||||||
|
nvs_close(handle);
|
||||||
|
return ESP_ERR_INVALID_RESPONSE;
|
||||||
|
}
|
||||||
|
if (error != ESP_OK) {
|
||||||
|
nvs_close(handle);
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
if (size != sizeof(*blob)) {
|
||||||
|
nvs_close(handle);
|
||||||
|
return ESP_ERR_INVALID_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
memset(blob, 0, sizeof(*blob));
|
||||||
|
error = nvs_get_blob(handle, SSH_SECURITY_NVS_BLOB_KEY, blob, &size);
|
||||||
|
nvs_close(handle);
|
||||||
|
if (error == ESP_ERR_NVS_INVALID_LENGTH) {
|
||||||
|
return ESP_ERR_INVALID_VERSION;
|
||||||
|
}
|
||||||
|
return error == ESP_OK ? validate_blob(blob) : error;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void install_blob(const ssh_security_blob_t *candidate)
|
||||||
|
{
|
||||||
|
secure_wipe(&s_material, sizeof(s_material));
|
||||||
|
s_material = *candidate;
|
||||||
|
s_material_ready = true;
|
||||||
|
s_load_result = SSH_SECURITY_LOAD_STORED;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t ssh_security_init(ssh_security_load_result_t *load_result)
|
||||||
|
{
|
||||||
|
esp_err_t error = secure_random_init();
|
||||||
|
if (error != ESP_OK) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
error = ensure_mutex();
|
||||||
|
if (error != ESP_OK) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||||
|
if (s_material_ready) {
|
||||||
|
if (load_result != NULL) {
|
||||||
|
*load_result = s_load_result;
|
||||||
|
}
|
||||||
|
xSemaphoreGive(s_security_mutex);
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh_security_blob_t candidate;
|
||||||
|
bool missing = false;
|
||||||
|
error = load_blob(&candidate, &missing);
|
||||||
|
if (error == ESP_OK && missing) {
|
||||||
|
error = generate_blob(&candidate, 1U);
|
||||||
|
if (error == ESP_OK) {
|
||||||
|
error = save_blob(&candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (error == ESP_OK) {
|
||||||
|
s_material = candidate;
|
||||||
|
s_material_ready = true;
|
||||||
|
s_load_result = missing ? SSH_SECURITY_LOAD_GENERATED_MISSING
|
||||||
|
: SSH_SECURITY_LOAD_STORED;
|
||||||
|
if (load_result != NULL) {
|
||||||
|
*load_result = s_load_result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
secure_wipe(&candidate, sizeof(candidate));
|
||||||
|
xSemaphoreGive(s_security_mutex);
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t ssh_security_copy_private_key(uint8_t *output, size_t capacity,
|
||||||
|
size_t *output_length)
|
||||||
|
{
|
||||||
|
if (output_length == NULL || (output == NULL && capacity != 0U)) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
*output_length = s_material.private_key_length;
|
||||||
|
if (output == NULL) {
|
||||||
|
error = capacity == 0U ? ESP_OK : ESP_ERR_INVALID_ARG;
|
||||||
|
} else if (capacity < s_material.private_key_length) {
|
||||||
|
error = ESP_ERR_INVALID_SIZE;
|
||||||
|
} else {
|
||||||
|
memcpy(output, s_material.private_key_der,
|
||||||
|
s_material.private_key_length);
|
||||||
|
error = ESP_OK;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
xSemaphoreGive(s_security_mutex);
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t ssh_security_get_metadata(ssh_security_metadata_t *metadata)
|
||||||
|
{
|
||||||
|
if (metadata == NULL) {
|
||||||
|
return ESP_ERR_INVALID_ARG;
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
memset(metadata, 0, sizeof(*metadata));
|
||||||
|
metadata->generation = s_material.generation;
|
||||||
|
memcpy(metadata->sha256_fingerprint, s_material.sha256_fingerprint,
|
||||||
|
sizeof(metadata->sha256_fingerprint));
|
||||||
|
error = ESP_OK;
|
||||||
|
}
|
||||||
|
xSemaphoreGive(s_security_mutex);
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t ssh_security_rotate(void)
|
||||||
|
{
|
||||||
|
if (s_security_mutex == NULL) {
|
||||||
|
return ESP_ERR_INVALID_STATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||||
|
esp_err_t error = ESP_ERR_INVALID_STATE;
|
||||||
|
ssh_security_blob_t candidate;
|
||||||
|
memset(&candidate, 0, sizeof(candidate));
|
||||||
|
if (s_material_ready && s_material.generation != UINT32_MAX) {
|
||||||
|
error = generate_blob(&candidate, s_material.generation + 1U);
|
||||||
|
if (error == ESP_OK) {
|
||||||
|
error = save_blob(&candidate);
|
||||||
|
}
|
||||||
|
if (error == ESP_OK) {
|
||||||
|
install_blob(&candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
secure_wipe(&candidate, sizeof(candidate));
|
||||||
|
xSemaphoreGive(s_security_mutex);
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t ssh_security_reset(void)
|
||||||
|
{
|
||||||
|
esp_err_t error = secure_random_init();
|
||||||
|
if (error != ESP_OK) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
error = ensure_mutex();
|
||||||
|
if (error != ESP_OK) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||||
|
uint32_t generation = 1U;
|
||||||
|
if (s_material_ready) {
|
||||||
|
if (s_material.generation == UINT32_MAX) {
|
||||||
|
xSemaphoreGive(s_security_mutex);
|
||||||
|
return ESP_ERR_INVALID_STATE;
|
||||||
|
}
|
||||||
|
generation = s_material.generation + 1U;
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh_security_blob_t candidate;
|
||||||
|
error = generate_blob(&candidate, generation);
|
||||||
|
if (error == ESP_OK) {
|
||||||
|
error = save_blob(&candidate);
|
||||||
|
}
|
||||||
|
if (error == ESP_OK) {
|
||||||
|
install_blob(&candidate);
|
||||||
|
}
|
||||||
|
secure_wipe(&candidate, sizeof(candidate));
|
||||||
|
xSemaphoreGive(s_security_mutex);
|
||||||
|
return error;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||||
|
/* Persistent SSH host identity, separate from the HTTPS certificate key. */
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include "esp_err.h"
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define SSH_SECURITY_NVS_NAMESPACE "ssh_sec"
|
||||||
|
#define SSH_SECURITY_NVS_BLOB_KEY "material"
|
||||||
|
#define SSH_SECURITY_PRIVATE_KEY_DER_CAPACITY 256U
|
||||||
|
#define SSH_SECURITY_SHA256_LENGTH 32U
|
||||||
|
|
||||||
|
#define SSH_SECURITY_KEY_TYPE "ecdsa-sha2-nistp256"
|
||||||
|
#define SSH_SECURITY_CURVE_NAME "nistp256"
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
SSH_SECURITY_LOAD_STORED = 0,
|
||||||
|
SSH_SECURITY_LOAD_GENERATED_MISSING = 1,
|
||||||
|
} ssh_security_load_result_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint32_t generation;
|
||||||
|
uint8_t sha256_fingerprint[SSH_SECURITY_SHA256_LENGTH];
|
||||||
|
} ssh_security_metadata_t;
|
||||||
|
|
||||||
|
/* NVS and secure_random must be ready. Existing malformed material is not replaced. */
|
||||||
|
esp_err_t ssh_security_init(ssh_security_load_result_t *load_result);
|
||||||
|
|
||||||
|
/* Query with output NULL/capacity zero; the required length is always returned. */
|
||||||
|
esp_err_t ssh_security_copy_private_key(uint8_t *output, size_t capacity,
|
||||||
|
size_t *output_length);
|
||||||
|
esp_err_t ssh_security_get_metadata(ssh_security_metadata_t *metadata);
|
||||||
|
|
||||||
|
/* Caller must stop SSH first. Rotation requires valid live material; reset replaces any stored state. */
|
||||||
|
esp_err_t ssh_security_rotate(void);
|
||||||
|
esp_err_t ssh_security_reset(void);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
+1153
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
|||||||
|
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||||
|
/* Authenticated, bounded wolfSSH transport for the serial session broker. */
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include "esp_err.h"
|
||||||
|
#include "session_broker.h"
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define SSH_TRANSPORT_PORT 22U
|
||||||
|
#define SSH_TRANSPORT_MAX_SESSIONS 2U
|
||||||
|
#define SSH_TRANSPORT_IO_BUFFER_SIZE 512U
|
||||||
|
#define SSH_TRANSPORT_HANDSHAKE_TIMEOUT_SECONDS 15U
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
SSH_TRANSPORT_SESSION_FREE = 0,
|
||||||
|
SSH_TRANSPORT_SESSION_HANDSHAKE,
|
||||||
|
SSH_TRANSPORT_SESSION_ACTIVE,
|
||||||
|
SSH_TRANSPORT_SESSION_CLOSING,
|
||||||
|
} ssh_transport_session_state_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint64_t starts;
|
||||||
|
uint64_t start_failures;
|
||||||
|
uint64_t stops;
|
||||||
|
uint64_t tcp_connections;
|
||||||
|
uint64_t capacity_rejections;
|
||||||
|
uint64_t handshake_successes;
|
||||||
|
uint64_t handshake_failures;
|
||||||
|
uint64_t handshake_timeouts;
|
||||||
|
uint64_t authentication_attempts;
|
||||||
|
uint64_t authentication_failures;
|
||||||
|
uint64_t request_rejections;
|
||||||
|
uint64_t broker_connections;
|
||||||
|
uint64_t broker_failures;
|
||||||
|
uint64_t disconnections;
|
||||||
|
uint64_t writer_requests;
|
||||||
|
uint64_t writer_grants;
|
||||||
|
uint64_t writer_denials;
|
||||||
|
uint64_t writer_revocations;
|
||||||
|
uint64_t rx_bytes;
|
||||||
|
uint64_t rx_accepted_bytes;
|
||||||
|
uint64_t rx_rejected_bytes;
|
||||||
|
uint64_t tx_bytes;
|
||||||
|
uint64_t io_failures;
|
||||||
|
uint64_t session_revocations;
|
||||||
|
} ssh_transport_counters_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
bool active;
|
||||||
|
bool authenticated;
|
||||||
|
bool writer;
|
||||||
|
bool close_requested;
|
||||||
|
bool rx_pending;
|
||||||
|
bool tx_pending;
|
||||||
|
uint32_t session_id;
|
||||||
|
uint32_t generation;
|
||||||
|
int socket_fd;
|
||||||
|
session_broker_client_id_t broker_client_id;
|
||||||
|
ssh_transport_session_state_t state;
|
||||||
|
char peer[48];
|
||||||
|
} ssh_transport_session_snapshot_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
bool initialized;
|
||||||
|
bool running;
|
||||||
|
bool transitioning;
|
||||||
|
uint16_t port;
|
||||||
|
esp_err_t last_error;
|
||||||
|
uint32_t active_sessions;
|
||||||
|
ssh_transport_session_snapshot_t sessions[SSH_TRANSPORT_MAX_SESSIONS];
|
||||||
|
ssh_transport_counters_t counters;
|
||||||
|
} ssh_transport_snapshot_t;
|
||||||
|
|
||||||
|
/* Installs wolfCrypt RNG/PSRAM hooks and starts the sole wolfSSH owner task. */
|
||||||
|
esp_err_t ssh_transport_init(void);
|
||||||
|
esp_err_t ssh_transport_start(void);
|
||||||
|
esp_err_t ssh_transport_stop(void);
|
||||||
|
|
||||||
|
/* Serialize stop, persistent host-key replacement, and conditional restart. */
|
||||||
|
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_clear_counters(void);
|
||||||
|
|
||||||
|
/* Close one transport session or all authenticated/handshaking sessions. */
|
||||||
|
esp_err_t ssh_transport_disconnect(uint32_t session_id);
|
||||||
|
esp_err_t ssh_transport_revoke_sessions(void);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
+24
-8
@@ -1,5 +1,5 @@
|
|||||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||||
/* UART0 HTTPS lifecycle, credentials, certificate, and recovery commands. */
|
/* UART0 HTTPS lifecycle, shared credentials, certificate, and recovery commands. */
|
||||||
|
|
||||||
#include "web_console.h"
|
#include "web_console.h"
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
#include "esp_console.h"
|
#include "esp_console.h"
|
||||||
#include "secure_random.h"
|
#include "secure_random.h"
|
||||||
|
#include "ssh_transport.h"
|
||||||
#include "web_security.h"
|
#include "web_security.h"
|
||||||
#include "web_serial_transport.h"
|
#include "web_serial_transport.h"
|
||||||
#include "web_server.h"
|
#include "web_server.h"
|
||||||
@@ -52,7 +53,7 @@ static int show_status(void)
|
|||||||
(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 (security_error == ESP_OK) {
|
||||||
printf("Authentication: HTTP Basic over TLS, username=%.*s, material=ready\n",
|
printf("Authentication: HTTP Basic over TLS, username=%.*s, shared with SSH\n",
|
||||||
(int)username_length, username);
|
(int)username_length, username);
|
||||||
} else {
|
} else {
|
||||||
printf("Authentication material unavailable: %s; use 'web reset --force' to replace it.\n",
|
printf("Authentication material unavailable: %s; use 'web reset --force' to replace it.\n",
|
||||||
@@ -167,7 +168,7 @@ 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 only. Keep them private.\n");
|
printf("These credentials protect HTTPS and SSH. Keep them private.\n");
|
||||||
secure_wipe(&credentials, sizeof(credentials));
|
secure_wipe(&credentials, sizeof(credentials));
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -231,11 +232,16 @@ static int rotate_credentials(void)
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t revoke_error = web_serial_transport_revoke_sessions();
|
esp_err_t web_revoke_error = web_serial_transport_revoke_sessions();
|
||||||
printf("Web credentials rotated and persisted. Existing Basic credentials are now invalid.\n");
|
esp_err_t ssh_revoke_error = ssh_transport_revoke_sessions();
|
||||||
if (revoke_error != ESP_OK && revoke_error != ESP_ERR_INVALID_STATE) {
|
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",
|
printf("Warning: existing WebSocket sessions could not be revoked: %s\n",
|
||||||
esp_err_to_name(revoke_error));
|
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,
|
||||||
@@ -272,7 +278,17 @@ static int reset_material(void)
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
printf("Web credentials, certificate, and private key replaced and persisted.\n");
|
esp_err_t web_revoke_error = web_serial_transport_revoke_sessions();
|
||||||
|
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);
|
||||||
|
|||||||
+13
-2
@@ -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 administrative credentials. */
|
/* Canonical NVS storage for HTTPS identity and shared admin credentials. */
|
||||||
|
|
||||||
#include "web_security.h"
|
#include "web_security.h"
|
||||||
|
|
||||||
@@ -753,7 +753,7 @@ static esp_err_t credential_digest(const uint8_t *username, size_t username_leng
|
|||||||
return result == 0 ? ESP_OK : ESP_FAIL;
|
return result == 0 ? ESP_OK : ESP_FAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t web_security_authenticate_basic(const uint8_t *username,
|
esp_err_t web_security_authenticate_admin(const uint8_t *username,
|
||||||
size_t username_length,
|
size_t username_length,
|
||||||
const uint8_t *password,
|
const uint8_t *password,
|
||||||
size_t password_length,
|
size_t password_length,
|
||||||
@@ -800,6 +800,17 @@ esp_err_t web_security_authenticate_basic(const uint8_t *username,
|
|||||||
return error;
|
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)
|
||||||
{
|
{
|
||||||
|
|||||||
+9
-2
@@ -1,5 +1,5 @@
|
|||||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||||
/* Persistent HTTPS identity and administrative Basic credentials. */
|
/* Persistent HTTPS identity and shared network-administration credentials. */
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
@@ -75,7 +75,14 @@ esp_err_t web_security_copy_tls_material(
|
|||||||
esp_err_t web_security_copy_username(char *output, size_t capacity,
|
esp_err_t web_security_copy_username(char *output, size_t capacity,
|
||||||
size_t *output_length);
|
size_t *output_length);
|
||||||
|
|
||||||
/* Input fields are decoded HTTP Basic components, not the base64 header text. */
|
/* 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,
|
esp_err_t web_security_authenticate_basic(const uint8_t *username,
|
||||||
size_t username_length,
|
size_t username_length,
|
||||||
const uint8_t *password,
|
const uint8_t *password,
|
||||||
|
|||||||
Reference in New Issue
Block a user