|
DeterministicESPAsyncWebServer v6.27.1
Zero-allocation, bounded-execution async HTTP server for ESP32
|
This document covers the security posture of the entire library. Each section names the specific files that implement or enforce the property described. Where a security decision appears in source-code documentation, this document links to it and explains the broader context.
A candid assessment of where the library stands today. ✅ = solid, ⚠️ = acceptable with caveats, ❌ = a real weakness to be aware of.
| Area | Why it's solid |
|---|---|
| Deterministic memory | Zero heap after `begin()`; every buffer is fixed-size and bounds-checked. No use-after-free, no fragmentation, no allocation failure paths. |
| HTTP input validation | RFC 7230 parser validates every byte; rejects malformed method/path/headers, enforces Host and Content-Length, refuses Transfer-Encoding (no request smuggling surface). |
| WebSocket framing | Enforces client masking, reserved-opcode/RSV checks, control-frame size and fragmentation rules. |
| SSH crypto correctness | SHA-256/HMAC/AES-CTR/DH validated against NIST/RFC vectors; RSA verification validated against an openssl KAT and native RSA signing against a sign→verify round-trip with a real 2048-bit private exponent. MAC-verify-before-use; constant-time MAC compare. |
| Secret hygiene | RSA private key never in static memory (NVS→stack→wipe); session keys, DH secrets, and scratch buffers volatile-wiped after use; key pools are separate linker symbols. |
| SSH hardening option | DETWS_SSH_ALLOW_PASSWORD=0 compiles password auth out entirely for publickey-only deployments. |
| Area | Caveat |
|---|---|
| Transport encryption (HTTP) | Opt-in HTTPS (`DETWS_ENABLE_TLS`) via mbedTLS on a static memory pool - TLS 1.2+/ECDHE-ECDSA-AES256-GCM-SHA384, zero-heap (§6). Default off (plain HTTP). Optional mutual TLS (`DETWS_ENABLE_MTLS`) adds client-certificate authentication (handshake requires a cert chaining to a configured CA), and wss:// + TLS-SSE run encrypted over the same TLS record layer. Optional session resumption (`DETWS_ENABLE_TLS_RESUMPTION`) via RFC 5077 tickets gives returning clients an abbreviated handshake. Caveat: one connection at a time (MAX_TLS_CONNS=1). On a trusted LAN plain HTTP is fine; the SSH layer is also an encrypted channel. |
| SSH timing side-channels | The native software bignum/AES/RSA paths are not constant-time, but they are compile-excluded from firmware: the software Montgomery cluster is under #ifndef ARDUINO (ssh_bignum.cpp) and the software AES / native RSA modexp live in the #else of an #ifdef ARDUINO (ssh_aes256ctr.cpp, ssh_rsa.cpp). On ESP32 only the hardware/mbedTLS paths are compiled and run; the software paths exist solely for host testing. |
Date response header | Not emitted (the device usually has no wall clock). RFC 7231 §7.1.1.2 permits this for clock-less servers. |
| Single SSH channel | One session channel per connection; no port-forwarding/X11. Smaller attack surface, but a functional limit. |
| Diagnostic endpoint | `DETWS_ENABLE_DIAG` leaks build configuration; default-off and must stay off in production. |
| SNMP agent (v1/v2c) | Opt-in (`DETWS_ENABLE_SNMP`, default off). Community-string access only - the community is sent in cleartext and is not real authentication (§10). Read-only by default; Set is refused unless a separate read-write community is configured. Run only on a trusted/management network and rename the default public/private communities. For authenticated + encrypted access, enable SNMPv3 / USM (`DETWS_ENABLE_SNMP_V3`: HMAC-SHA-256 auth + AES-128 privacy). |
| JWT bearer auth | Opt-in (`DETWS_ENABLE_JWT`, default off). HS256 only - a single shared secret both signs and verifies, so any holder of the secret can mint tokens; keep it server-side. Signature compare is constant-time. The verifier checks the signature and can read integer claims (e.g. exp), but does not itself enforce expiry - gate on the claim in your handler. Send tokens only over HTTPS (a bearer token is a password). |
| Outbound HTTPS client | Opt-in (`DETWS_ENABLE_HTTP_CLIENT_TLS`, default off). Encrypt-only by default (no trust store): resists passive eavesdropping but not an active MITM. Authenticate the peer by installing a CA trust anchor (http_client_set_ca()) - verifies the chain + hostname - and/or a SHA-256 certificate pin (http_client_set_pin()); a failure aborts the connection. Without one, treat secrets you send as MITM-exposed. |
| MQTT client | Opt-in (`DETWS_ENABLE_MQTT`, default off). Plain MQTT sends the broker username/password and all payloads in cleartext - use it only on a trusted segment. For an untrusted network use mqtts:// (`DETWS_ENABLE_MQTT_TLS`), which shares the client-TLS trust model (encrypt-only by default; install a CA / cert pin via det_tls_client_set_ca / det_tls_client_set_pin to authenticate the broker). |
| WebDAV share | Opt-in (`DETWS_ENABLE_WEBDAV`, default off). A dav() mount exposes a read/write view of the filesystem (PUT/DELETE/MKCOL/COPY/MOVE) with no authentication of its own - an unprotected mount lets anyone on the network read, overwrite, and delete files. Gate the mount with per-route auth and serve it over HTTPS, and front it with the accept throttles. Locks are advisory (a token is issued but not enforced), so they do not prevent concurrent writers. The .. traversal guard keeps requests inside the mount root. |
| Modbus TCP slave | Opt-in (`DETWS_ENABLE_MODBUS`, default off). Modbus has no authentication or encryption - any client that reaches port 502 can read and write the entire data model (coils and holding registers). This is inherent to the protocol. Run it only on an isolated/trusted control network (or behind a VPN), front it with the per-IP accept throttle, and keep safety-critical logic from depending solely on client-supplied register values. Reads/writes are bounds-checked against the configured table sizes (out-of-range -> exception 0x02). |
| OPC UA Binary server | Opt-in (`DETWS_ENABLE_OPCUA`, default off). Runs SecurityPolicy None with an Anonymous user identity - messages on TCP/4840 are neither signed nor encrypted, so any client that reaches the port can read/write/browse whatever the registered resolvers expose (Sign / SignAndEncrypt are not implemented). Run it only on an isolated/trusted control network (or behind a VPN), front it with the per-IP accept throttle, and gate writes in the write resolver (return a Bad StatusCode to refuse). |
| Area | Status |
|---|---|
| Connection-rate throttling | SSH bounds failed auth attempts per connection (`SSH_MAX_AUTH_ATTEMPTS`, then `SSH_MSG_DISCONNECT`); HTTP closes the socket on every 401 (one guess per connection). Across connections, two opt-in accept-path throttles bound reconnect/brute-force churn: a global fixed-window limit (`DETWS_ENABLE_ACCEPT_THROTTLE`) and a per-source-IPv4 fixed-window limit (`DETWS_ENABLE_PER_IP_THROTTLE`, a bounded BSS bucket table - so one noisy client is throttled without affecting others). Both are best-effort and IPv4-keyed; an attacker spreading across many source addresses can still churn the bounded pool, so also filter at the network layer. |
| Replay/DoS on the accept path | A flood of connections exhausts the fixed pool (by design - no heap), returning 503; the accept-path throttles above reduce churn but there is no allow-list or SYN-cookie equivalent. |
| Formal audit | The crypto and protocol code is vector-tested and reviewed, but has not had an independent security audit. Treat accordingly for high-value deployments. |
The remaining sections document each property in depth.
In scope (attacks this library defends against):
e is validated (1 < e < p-1) before any computation.Out of scope (not defended here):
Files: src/network_drivers/transport/tcp.cpp, src/network_drivers/presentation/presentation.cpp, src/ServerConfig.h
Every buffer that can hold user-supplied data is a fixed-size array in BSS, sized at compile time:
| Buffer | Symbol | Size |
|---|---|---|
| TCP ring buffer per connection | conn_pool[i].rx_buffer | `RX_BUF_SIZE` bytes |
| HTTP request body | http_pool[i].body | BODY_BUF_SIZE + 1 bytes |
| WebSocket frame payload | ws_pool[i].buf | `WS_FRAME_SIZE` bytes |
| SSH packet receive | ssh_pkt[i].rx_buf | SSH_RX_BUF_SIZE bytes |
No malloc, new, or pvPortMalloc is called after begin() for any of these paths. The total footprint is a compile-time constant; there is no fragmentation, no use-after-free, and no heap spray surface.
Each buffer is separately named in BSS. An overflow inside one connection's ring buffer cannot reach another connection's data without crossing the entire conn_pool[] symbol and then whatever the linker places next - it cannot reach the SSH key store in a single linear stride (see §7.7).
ServerConfig.h contains #error guards that catch impossible combinations at build time:
This prevents the common embedded mistake of configuring a body buffer larger than the transport ring buffer, which would cause the body parser to believe it has buffered data that was never written.
Files: src/network_drivers/presentation/http_parser/http_parser.cpp, src/network_drivers/presentation/http_parser/http_parser.h
The parser validates every byte of every user-controlled field before it is stored or acted upon. This is a defence-in-depth measure: even if a buffer overflow were possible, the parser rejects payloads that contain non-printing or control characters before they enter any buffer.
| Field | Allowed characters | RFC reference | Violation response |
|---|---|---|---|
| Method | tchar (‘ALPHA DIGIT ! # $ % & ’ * + - . ^ _ ` | ~) \ilinebr </td> <td class="markdownTableBodyNone"> RFC 7230 §3.1.1 \ilinebr </td> <td class="markdownTableBodyNone"> 400 \ilinebr </td> </tr> <tr class="markdownTableRowEven"> <td class="markdownTableBodyNone"> Path \ilinebr </td> <td class="markdownTableBodyNone">VCHAR(x21–7E) \ilinebr </td> <td class="markdownTableBodyNone"> RFC 3986 §3.3 \ilinebr </td> <td class="markdownTableBodyNone"> 400 \ilinebr </td> </tr> <tr class="markdownTableRowOdd"> <td class="markdownTableBodyNone"> Query \ilinebr </td> <td class="markdownTableBodyNone">VCHAR(x21–7E) \ilinebr </td> <td class="markdownTableBodyNone"> RFC 3986 §3.4 \ilinebr </td> <td class="markdownTableBodyNone"> 400 \ilinebr </td> </tr> <tr class="markdownTableRowEven"> <td class="markdownTableBodyNone"> Header field-name \ilinebr </td> <td class="markdownTableBodyNone">tchar\ilinebr </td> <td class="markdownTableBodyNone"> RFC 7230 §3.2 \ilinebr </td> <td class="markdownTableBodyNone"> 400 \ilinebr </td> </tr> <tr class="markdownTableRowOdd"> <td class="markdownTableBodyNone"> Header field-value \ilinebr </td> <td class="markdownTableBodyNone">VCHAR`, SP, HTAB, obs-text (x80–FF) | RFC 7230 §3.2 | 400 |
| Field | Limit | Violation response |
|---|---|---|
| Path | MAX_PATH_LEN - 1 bytes | 414 URI Too Long |
| Body | `BODY_BUF_SIZE` bytes | 413 Payload Too Large |
| Header field-name | `MAX_KEY_LEN` bytes | silently truncated |
| Header field-value | `MAX_VAL_LEN` bytes | silently truncated |
Path and body oversize conditions are detected before any byte is written to the destination buffer and result in an error response sent immediately.
A bare CR (\r) inside a header field-name transitions the parser to an error state (400). This prevents HTTP response splitting and CRLF injection via carefully crafted header values that contain \r\n.
The library does not support chunked transfer encoding. Any request containing a Transfer-Encoding header is rejected with 501 Not Implemented. This closes the "HTTP request smuggling via chunked+content-length desync" attack class for the server side.
Files: src/network_drivers/presentation/websocket/websocket.cpp, src/network_drivers/presentation/websocket/websocket.h
The WebSocket upgrade handshake requires:
GET request (any other method is rejected with 400).Connection: Upgrade header (case-insensitive match).Upgrade: websocket header (case-insensitive match).Sec-WebSocket-Key header containing a 24-character base64 value (16 decoded bytes).The accept key is computed as SHA-1(client_key + WS_GUID) using the mbedTLS hardware accelerator on ESP32. The GUID (258EAFA5-E914-47DA-95CA-C5AB0DC85B11) is defined in RFC 6455 §1.3 and prevents cross-protocol attacks where an HTTP server is tricked into treating a non-WebSocket request as one.
WS_FRAME_SIZE-byte frame. Oversized frames are rejected.The library does not validate the Origin header by default. For internet- facing deployments, the application should check http_get_header(req, "Origin") in the WebSocket route handler and reject origins that are not in an allowlist.
Files: src/network_drivers/presentation/presentation.cpp, src/ServerConfig.h
HTTP Basic Auth credentials are compared after base64-decoding the Authorization header value. The comparison uses the standard library strncmp. This comparison is NOT constant-time and is therefore susceptible to timing side channels if an attacker can measure the response time precisely.
For embedded device use this is generally acceptable because:
Credentials passed to server.on(path, method, handler, realm, user, pass) are stored in the application's .rodata or .text section. They are never copied to heap or writable RAM by the library. Flash memory is read-only; an attacker who has read access to writable RAM cannot extract the credential via a memory read exploit.
HTTPS is available as an opt-in layer (DETWS_ENABLE_TLS, default off) built on mbedTLS, designed so it does not break the library's zero-heap / determinism guarantee. With it disabled, no TLS code is compiled and the build is unchanged.
mbedTLS normally heap-allocates per connection (TLS context, ~16 KB IN + 16 KB OUT record buffers, handshake temporaries). Here every mbedTLS allocation is redirected to a fixed BSS arena (DETWS_TLS_ARENA_SIZE) via a custom first-fit allocator installed with mbedtls_platform_set_calloc_free() - so the system heap is never touched after begin(). The arena is sized for the worst-case handshake; if it is ever too small the handshake fails cleanly (allocation returns NULL → connection dropped), never corrupting memory. The live high-water mark is queryable via `det_tls_arena_peak()`. Measured peak for one ECDSA P-256 connection on Arduino-esp32 (16 KB IN/OUT records) is ~41.5 KB; the default arena is 48 KB.
openssl s_client + browser), not in the native suite. The arena allocator's accounting is the unit-testable part.MBEDTLS_SSL_VERSION_TLS1_2); TLS 1.3 is negotiated when the client offers it.ECDHE-ECDSA-AES256-GCM-SHA384 - forward secrecy via ephemeral ECDHE, ECDSA server authentication, AEAD AES-256-GCM (hardware-accelerated on the ESP32 AES/SHA engines).esp_fill_random) feeds mbedTLS directly (mbedtls_ssl_conf_rng); no software DRBG state to seed or persist.begin_tls() / tls_cert() into the static pool. An ECDSA P-256 cert is recommended (faster handshake, smaller than RSA). The private key lives in the static arena for the device's lifetime (it is not wiped per-connection like the SSH host key).MAX_TLS_CONNS = 1). A second concurrent TLS connection roughly doubles the ~32 KB record-buffer cost, which overflows the static DRAM budget on a stock Arduino build; raising it requires shrinking the IDF record sizes (CONFIG_MBEDTLS_SSL_IN/OUT_CONTENT_LEN, an ESP-IDF-framework build).wss:// and TLS Server-Sent Events run over the record layer** when TLS is enabled: a WebSocket/SSE upgrade on a TLS connection is encrypted frame-by-frame (transparent to handler code), not rejected. Plain ws:///SSE on a non-TLS listener is unaffected.examples/L4-Transport/03.HTTPS is a public throwaway - generate your own key/cert and keep the key secret.Files:
RFCs: RFC 3526 §3 (MODP group 14), RFC 8268 §3 (SHA-256 with group14)
The SSH key exchange uses Diffie-Hellman with the 2048-bit MODP group 14 prime (p) and generator g=2. The exchange hash uses SHA-256.
Why group14?
Group14 (2048-bit) provides approximately 112 bits of security (NIST SP 800-131A equivalent), which is the minimum recommended for new deployments as of 2024. It is widely supported by existing SSH clients. Group16 (4096-bit) provides ~140 bits but is 4× slower on ESP32; the hardware mbedTLS path makes group14 fast enough.
Protocol flow
Client value validation
The received client public value e is checked against 1 and p-1 by `bn_dh_validate()` before any computation. A value of 1 leaks the server scalar y directly (K = 1^y = 1, fixed). A value of p-1 causes K to be either 1 or p-1 (depending on y parity), which is also a fixed value. Both are well-known small-subgroup attacks specified in RFC 4253 §8.
Private scalar generation
The server's private scalar y is generated by esp_fill_random() (hardware RNG on ESP32; see §7.10). The top two bits are masked to ensure y < p; the least significant bit 1 is set to ensure y ≠ 0 and y ≠ 1. A fresh y is generated for every connection.
y is never reused across connections. Reuse of y with two different client values e₁, e₂ allows recovery of y via:
log_g(K₁) / log_g(K₂) = y (both sides known → y can be derived)
`ssh_dh_generate()` always generates fresh randomness; there is no caching.
RFC: RFC 4344 §4
AES-256-CTR is a stream cipher mode. The keystream is produced by encrypting a 128-bit counter with AES-256 and XOR-ing it with the plaintext.
Why CTR mode?
Counter management
The counter is initialised to IV_c2s or IV_s2c (derived from the KEX; see §7.5) and incremented as a big-endian 128-bit integer after each 16-byte block. The counter never repeats within a connection because:
Platform-specific implementations
mbedtls_aes_crypt_ecb() with the hardware AES accelerator inside the ESP32 crypto engine. The key schedule is managed by mbedtls.The platform is selected by #ifdef ARDUINO in ssh_aes256ctr.cpp. No guessed buffer sizes are used: the Arduino path embeds mbedtls_aes_context directly (by #include <mbedtls/aes.h>), so the compiler enforces the exact size. The "opaque buffer of guessed size" anti-pattern is explicitly rejected.
RFC: RFC 2104 (HMAC), RFC 6668 §2 (hmac-sha2-256 for SSH)
Every SSH binary packet carries a 32-byte HMAC-SHA2-256 MAC computed over:
HMAC-SHA256(mac_key, seq_no_be32 || packet_length || padding_length || payload || padding)
where seq_no_be32 is the 32-bit packet sequence number in big-endian encoding.
MAC key size
The mac keys (mac_key_c2s, mac_key_s2c) are 32 bytes each, derived from the key exchange (§7.5). HMAC-SHA256 block length is 64 bytes; a 32-byte key is shorter than the block length and is padded with zeros to 64 bytes internally. This is correct per RFC 2104 §2: keys shorter than B (block length) are right-padded with zeros.
Verify-before-use
The MAC is verified before the payload bytes are forwarded to any protocol handler. The verification uses a constant-time 32-byte comparison (ct_memcmp in ssh_packet.cpp) that accumulates XOR differences without early-exit branching. This prevents timing-oracle attacks where an attacker measures how many bytes of the MAC matched before a short-circuit return.
If MAC verification fails:
RFC: RFC 8332 §3 (rsa-sha2-256), RFC 8017 §8.2 (PKCS#1 v1.5 signature)
The server authenticates itself to the client by signing the exchange hash H with its RSA-2048 private key using PKCS#1 v1.5 and SHA-256.
Why PKCS#1 v1.5 and not RSA-PSS?
The SSH protocol specifies rsa-sha2-256 (RFC 8332), which is defined as PKCS#1 v1.5 with SHA-256 as per RFC 8017 §8.2. RSA-PSS is a different scheme (rsa-sha2-256 with PSS would require a separate algorithm identifier). All major SSH clients (OpenSSH ≥ 7.2) support rsa-sha2-256. This library implements the algorithm the RFC requires.
Signature construction
digest = SHA-256(H).sig = pad^d mod n (RSA private-key operation).The 256-byte signature is transmitted in the SSH_MSG_KEXDH_REPLY.
Key storage and loading
"ssh_host_key", key "priv_der".nvs_set_blob before the first connection is accepted. The library does not generate a key.For the private key lifetime policy, see §7.6.
Six values are derived from the shared secret K and exchange hash H:
| Label | Use | Size |
|---|---|---|
‘'A’\ilinebr </td> <td class="markdownTableBodyNone"> IV_c2s (AES-CTR IV, client→server) \ilinebr </td> <td class="markdownTableBodyNone"> first 16 bytes \ilinebr </td> </tr> <tr class="markdownTableRowEven"> <td class="markdownTableBodyNone">'B'\ilinebr </td> <td class="markdownTableBodyNone"> IV_s2c (AES-CTR IV, server→client) \ilinebr </td> <td class="markdownTableBodyNone"> first 16 bytes \ilinebr </td> </tr> <tr class="markdownTableRowOdd"> <td class="markdownTableBodyNone">'C'\ilinebr </td> <td class="markdownTableBodyNone"> key_c2s (AES-256 key, client→server) \ilinebr </td> <td class="markdownTableBodyNone"> 32 bytes \ilinebr </td> </tr> <tr class="markdownTableRowEven"> <td class="markdownTableBodyNone">'D'\ilinebr </td> <td class="markdownTableBodyNone"> key_s2c (AES-256 key, server→client) \ilinebr </td> <td class="markdownTableBodyNone"> 32 bytes \ilinebr </td> </tr> <tr class="markdownTableRowOdd"> <td class="markdownTableBodyNone">'E'\ilinebr </td> <td class="markdownTableBodyNone"> mac_c2s (HMAC-SHA2-256 key, client→server) \ilinebr </td> <td class="markdownTableBodyNone"> 32 bytes \ilinebr </td> </tr> <tr class="markdownTableRowEven"> <td class="markdownTableBodyNone">'F'` | mac_s2c (HMAC-SHA2-256 key, server→client) | 32 bytes |
Each value is derived as:
SHA-256(mpint(K) || H || label || session_id)
where for the first (and only, in this implementation) KEX, session_id = H.
K is encoded as an SSH mpint: a 4-byte big-endian length followed by an optional 0x00 prefix byte (required if the MSB of K is set, to indicate a positive integer), followed by the 256-byte big-endian value of K.
After derivation, all stack temporaries (key_c2s, key_s2c, iv_c2s, iv_s2c, the SHA-256 context) are zeroed via ssh_wipe() before `ssh_dh_derive_keys()` returns.
This is the most security-critical design decision in the SSH implementation. The same principle is documented in the source at the top of ssh_rsa.h.
Why the private key must never be in static memory
If the RSA-2048 private key (d, p, q, dp, dq, qinv) were stored in a global or static array:
conn_pool[i].rx_buffer and a hypothetical rsa_private_key[256] stored in BSS.The mandated lifetime
The exposure window is the duration of a single RSA-2048 sign operation, approximately 0.5–2 ms on ESP32. During this window, the key is on the stack, which is in SRAM. It is not accessible from a single linear overflow of any BSS buffer because the stack is a separate region with an opposite growth direction.
NVS encryption recommendation
The NVS partition should be encrypted using the ESP-IDF NVS encryption feature (enable CONFIG_NVS_ENCRYPTION in menuconfig, provision the NVS encryption key via nvs_flash_generate_keys()). Without NVS encryption, the private key is stored in plaintext flash and can be read by anyone with physical access to the device.
Source: ssh_keymat.h (Defence 1 comment block)
Three separate BSS symbols hold SSH state:
The linker assigns each symbol its own address. An overflow that starts inside ssh_pkt[i].rx_buf and continues linearly would first overwrite other `SshPacketState` fields, then overflow past the end of ssh_pkt[], then traverse whatever the linker placed between ssh_pkt and ssh_keys (which could be hundreds or thousands of bytes of other data), before reaching any byte of ssh_keys.
This is not obscurity - the principle is the same as used by hardware security modules that physically separate key memory from bus-accessible memory. The mechanism here is linker symbol isolation rather than hardware isolation, which raises the attack bar significantly without requiring hardware security features.
Contrast with the alternative: If all SSH state were in a single SshConn struct with fields in the order rx_buf, aes_key, hmac_key, then a one-byte overflow past rx_buf immediately reaches aes_key. The separate- symbol layout eliminates that risk.
Source: ssh_packet.h, ssh_packet.cpp
SSH sequence numbers are 32-bit unsigned integers (RFC 4253 §6.4). They wrap at 2^32. Two problems arise at wrap:
Policy: When seq_no_send or seq_no_recv reaches `SSH_SEQ_CLOSE_THRESHOLD` (0xFFFFFFF0 - 16 below the 32-bit maximum), the library closes the connection. `ssh_pkt_send()` returns -1 at this point; the caller is responsible for closing the TCP connection and notifying the client.
A rekeying implementation (SSH_MSG_KEXINIT exchange to install new keys and reset sequence numbers) would allow long-lived sessions. This is a known limitation and is noted in docs/CHANGELOG.md.
Source: ssh_keymat.h
Why not memset?
C and C++ compilers are explicitly permitted by the standard to elide a memset call whose result is "not observed" before the memory goes out of scope or is overwritten. This optimisation is commonly performed by:
-O2 or higher (-fdce / dead code elimination).-O1 or higher (same analysis).-Os optimisation level.A memset that the compiler removes silently leaves key bytes in memory. The volatile pointer cast forces every store to actually reach SRAM because volatile reads/writes are not subject to the "as-if rule" and cannot be removed.
Where ssh_wipe is used
| What is wiped | When | File |
|---|---|---|
crypto_work[1536] | After every `bn_expmod_group14()` call | ssh_bignum.cpp |
SshDhState.y, .K | After key derivation in `ssh_dh_finish()` | ssh_dh.cpp |
| `SshRsaPrivKey` (stack) | Before `ssh_rsa_sign()` returns | ssh_rsa.cpp |
| `SshKeyMat` | On connection close / error | ssh_keymat.h |
| `SshDhState` (full struct) | On connection close / error | ssh_keymat.h |
| DER private key stack copy | After mbedtls_pk_parse_key() in ssh_rsa_sign() | ssh_rsa.cpp |
| Key derivation stack temporaries | After ssh_dh_derive_keys() | ssh_dh.cpp |
Source: test/mocks/Arduino.h (native mock), ESP-IDF esp_random.h (Arduino production)
Production (Arduino / ESP32)
The server DH private scalar y and all random padding bytes are generated using esp_fill_random(), which wraps esp_random(). On ESP32:
esp_random() reads from the hardware RNG peripheral, which is seeded by thermal noise from the analog front-end and feeds into a 32-bit LFSR with hardware whitening.esp_fill_random(256 bytes) is effectively instantaneous.Native test environment
The native test mock in test/mocks/Arduino.h provides a time-seeded PRNG:
This is NOT cryptographically secure. It is used only for unit testing the structural correctness of the DH, RSA, and packet code. The tests that exercise these paths use known-value inputs where possible (small exponents, fixed test keys) and do not rely on the RNG producing cryptographically unpredictable output.
The native mock is clearly marked and will not compile on Arduino targets because test/mocks/Arduino.h is only included by the native_ssh PlatformIO environment.
File: dwserver.cpp
The optional DETWS_ENABLE_DIAG build flag enables a JSON endpoint at /diag that returns all active feature flags and configuration constants.
This endpoint exposes build configuration details that could assist an attacker in fingerprinting the firmware and calculating buffer sizes. It is disabled by default (DETWS_ENABLE_DIAG = 0). Do not enable it in production.
If enabled during development, protect it with HTTP Basic Auth:
| Limitation | Impact | Workaround |
|---|---|---|
| No SSH rekeying | Connection closed at seq ≈ 2^32 (≈ 4 billion packets) | Reconnect |
| No SSH user authentication | Any client that completes KEX is accepted | Add `SSH_MSG_USERAUTH_REQUEST` handler |
| HTTP Basic Auth is not constant-time | Timing oracle risk if measurable from network | Use TLS or SSH tunnel |
| Software AES/SHA paths are not constant-time | Test-only paths; not relevant in production | Production uses hardware AES (mbedTLS) |
| Outbound HTTPS client is encrypt-only by default | Without a CA/pin the client does not authenticate the peer (active MITM exposed) | Install a CA (http_client_set_ca) and/or SHA-256 cert pin (http_client_set_pin); treat secrets as MITM-exposed otherwise |
| No HSTS, CSP, or other HTTP security headers | Application must add headers manually | Call `send()` with appropriate headers |
| WebSocket Origin not validated | Cross-origin WebSocket requests accepted | Check Origin in ws_connect handler |
| NVS not encrypted by default | RSA private key readable from flash | Enable CONFIG_NVS_ENCRYPTION |
| SNMP v1/v2c community is cleartext | Anyone who can sniff UDP/161 learns the community | Trusted/management VLAN; rename communities; enable SNMPv3 USM authPriv (DETWS_ENABLE_SNMP_V3), or tunnel |
The optional SNMP agent (`DETWS_ENABLE_SNMP`, default off) implements SNMP v1 and v2c, whose security model is the community string - effectively a shared password sent in the clear in every datagram. It is convenient for monitoring on a trusted network but is not an authentication or confidentiality mechanism.
For authenticated and encrypted access, enable SNMPv3 / USM (`DETWS_ENABLE_SNMP_V3`, default off): a single authPriv user with usmHMAC192SHA256 authentication (HMAC-SHA-256) and usmAesCfb128 privacy (AES-128-CFB), engine discovery, and the RFC 3414 timeliness window against replay. The agent rejects unauthenticated non-discovery requests, verifies the HMAC before acting on any byte, and answers unknown users / wrong digests / out-of-window times with the standard USM Report PDUs. Keys are derived once from the passwords (RFC 3414 §2.6 localization), so no password material is recomputed per request. Operational notes: passwords must be >= 8 characters; persist and increment engineBoots in NVS (`snmp_v3_set_boots()`) so the timeliness window survives reboots; give each device a unique engine ID (derive from the MAC).
What the agent does enforce
public) authorizes only Get / GetNext / GetBulk. Set is refused (noAccess) unless a distinct read-write community is configured via `snmp_agent_set_rw_community()`; if none is set, the agent is effectively read-only. Writable objects must additionally opt in with a setter callback (others answer notWritable).GetBulk expansion is clamped to `SNMP_MAX_VARBINDS`, and an over-large response degrades to tooBig - so one request yields at most one bounded datagram (no unbounded amplification).What it does not provide
See RFC.md for the protocol-conformance details.
Use this checklist before deploying to a production environment.
DETWS_ENABLE_DIAG (default is off; confirm #define DETWS_ENABLE_DIAG 0)Origin header in WebSocket upgrade handlers if the device is accessible from untrusted networks.rodata), not computed from user input"ssh_host_key" / "priv_der")CONFIG_NVS_ENCRYPTION)nvs_keys partition) is stored in eFuse or secure flashknown_hosts)public / private are renamed to non-guessable valuesSet is actually required (read-only otherwise)engineBoots in NVS; auth/priv passwords are >= 8 chars and not the defaults-Os (default in ESP-IDF) - do not disable optimisation, as it is not needed for security and ssh_wipe is already volatile-correct#ifdef ARDUINO guard in production builds)pio test -e native_ssh to verify all SSH crypto test vectors pass before deployment