DeterministicESPAsyncWebServer v6.27.1
Zero-allocation, bounded-execution async HTTP server for ESP32
Loading...
Searching...
No Matches
auth.cpp
Go to the documentation of this file.
1// Copyright (C) 2026 Douglas Quigg (dstroy0) <dquigg123@gmail.com>
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4/**
5 * @file auth.cpp
6 * @brief HTTP authentication for DetWebServer: Basic (RFC 7617) and stateless Digest
7 * (RFC 7616, SHA-256, qop=auth).
8 *
9 * Split out of dwserver.cpp (single-purpose server files). Holds the Basic credential check,
10 * the Digest field parser, the keyed stateless-nonce mint/verify (no per-nonce server state),
11 * and the 401 challenge builder. The route dispatcher (dwserver.cpp) calls these DetWebServer
12 * methods when a matched route carries auth. Behaviour is identical to the pre-split code.
13 */
14
15#include "dwserver.h"
16#include "network_drivers/presentation/base64/base64.h" // base64_decode (Basic)
17#include "network_drivers/presentation/ssh/crypto/ssh_sha256.h" // ssh_sha256, SSH_SHA256_DIGEST_LEN (Digest)
18#include "network_drivers/transport/tcp.h" // conn_pool, det_conn_send, TcpConn/ConnState
19#include "server/dwserver_internal.h" // req_is_head, resp helpers
20#include "services/clock.h" // detws_millis() for the stateless nonce
21#include "shared_primitives/hex.h" // det_hex_encode/decode
22#include <stdio.h>
23#include <string.h>
24#if DETWS_ENABLE_AUTH
25#ifdef ARDUINO
26#include <esp_system.h> // esp_random() for the Digest keying secret
27#endif
28#endif
29// ---------------------------------------------------------------------------
30// Basic Auth helpers
31// ---------------------------------------------------------------------------
32
33#if DETWS_ENABLE_AUTH
34// One-shot SHA-256 of @p data, written as 64 lowercase hex chars + NUL.
35static void sha256_hex(const uint8_t *data, size_t len, char out[65])
36{
37 uint8_t d[SSH_SHA256_DIGEST_LEN];
38 ssh_sha256(data, len, d);
40}
41
42// Extract the value of @p key from a Digest auth header into @p out.
43// Handles both quoted ("value") and token (value) forms. The match must sit on
44// a field boundary (start, or after ' '/',') and be immediately followed by '='
45// so "nc" does not match inside "cnonce", etc.
46static bool digest_field(const char *hdr, const char *key, char *out, size_t out_size)
47{
48 size_t klen = strnlen(key, 32);
49 const char *p = hdr;
50 while ((p = strstr(p, key)) != nullptr)
51 {
52 bool left_ok = (p == hdr) || p[-1] == ' ' || p[-1] == ',';
53 const char *after = p + klen;
54 if (!left_ok || *after != '=')
55 {
56 p = after;
57 continue;
58 }
59 after++;
60 const char *vs;
61 const char *ve;
62 if (*after == '"')
63 {
64 vs = after + 1;
65 ve = strchr(vs, '"');
66 if (!ve)
67 return false;
68 }
69 else
70 {
71 vs = after;
72 ve = vs;
73 while (*ve && *ve != ',' && *ve != ' ')
74 ve++;
75 }
76 size_t vlen = (size_t)(ve - vs);
77 if (vlen > out_size - 1)
78 vlen = out_size - 1;
79 memcpy(out, vs, vlen);
80 out[vlen] = '\0';
81 return true;
82 }
83 return false;
84}
85
86void DetWebServer::regen_digest_secret()
87{
88 // Seed a 128-bit keying secret from the hardware CSPRNG (esp_random() on
89 // ESP32; a non-crypto mock on native test builds), folded through SHA-256 with
90 // a counter + millis() so even a weak host RNG yields distinct values across
91 // calls. The secret keys every timestamped nonce this server issues; it lives
92 // only in BSS and is never sent on the wire.
93 static uint32_t counter = 0;
94 counter++;
95 uint8_t seed[24];
96 for (int i = 0; i < 4; i++)
97 {
98 uint32_t r = esp_random();
99 memcpy(seed + i * 4, &r, 4);
100 }
101 uint32_t c = counter;
102 uint32_t t = (uint32_t)millis();
103 memcpy(seed + 16, &c, 4);
104 memcpy(seed + 20, &t, 4);
105 uint8_t d[SSH_SHA256_DIGEST_LEN];
106 ssh_sha256(seed, sizeof(seed), d);
107 memcpy(_digest_secret, d, sizeof(_digest_secret)); // first 128 bits
108}
109
110// Stateless Digest nonce (RFC 7616 3.3): "<issue_ms_hex>.<mac_hex>" where the MAC
111// is SHA-256(secret || issue_ms) truncated to 128 bits. The server holds no
112// per-nonce state - it recomputes the MAC to authenticate a returned nonce and
113// reads the embedded issue time to age it - so the scheme is safe under the
114// shared-nothing worker model (the secret is set once at begin(), read-only after).
115static uint32_t digest_nonce_mac(const uint8_t *secret, uint32_t issue, char *mac_hex)
116{
117 uint8_t material[20];
118 memcpy(material, secret, 16);
119 memcpy(material + 16, &issue, 4); // endian-symmetric: minted and verified the same way
120 uint8_t d[SSH_SHA256_DIGEST_LEN];
121 ssh_sha256(material, sizeof(material), d);
122 det_hex_encode(d, 16, mac_hex); // 16 bytes -> 32 hex chars + NUL
123 return issue;
124}
125
126void DetWebServer::make_digest_nonce(char *out, size_t cap)
127{
128 uint32_t issue = detws_millis();
129 char issue_hex[9];
130 det_hex_encode((const uint8_t *)&issue, 4, issue_hex); // 4 bytes -> 8 hex chars
131 char mac_hex[33];
132 digest_nonce_mac(_digest_secret, issue, mac_hex);
133 snprintf(out, cap, "%s.%s", issue_hex, mac_hex);
134}
135
136bool DetWebServer::verify_digest_nonce(const char *nonce, bool *expired)
137{
138 *expired = false;
139 // Expected shape: 8 hex (issue) + '.' + 32 hex (MAC).
140 if (strnlen(nonce, 42) != 8 + 1 + 32 || nonce[8] != '.')
141 return false;
142 uint32_t issue;
143 if (det_hex_decode(nonce, 8, (uint8_t *)&issue, 4) != 4)
144 return false;
145 char mac_hex[33];
146 digest_nonce_mac(_digest_secret, issue, mac_hex);
147 // Constant-time compare of the 32 MAC hex chars: a forged nonce never reveals
148 // how many leading characters matched.
149 const char *got = nonce + 9;
150 uint8_t diff = 0;
151 for (int i = 0; i < 32; i++)
152 diff |= (uint8_t)(mac_hex[i] ^ got[i]);
153 if (diff != 0)
154 return false; // not a nonce this server minted
155 uint32_t age = detws_millis() - issue; // unsigned: tolerant of the 32-bit millis wrap
156 *expired = (age > DETWS_DIGEST_NONCE_LIFETIME_MS);
157 return true;
158}
159
160void DetWebServer::send_unauth(uint8_t slot_id, const Route *r, bool stale)
161{
162 if (!det_conn_active(slot_id))
163 {
164 http_reset(slot_id);
165 return;
166 }
167
168 // Sized for the worst-case Digest challenge without truncation: the fixed field text (~76) + a
169 // max-length realm (MAX_AUTH_LEN-1) + the fixed 41-char nonce ("8hex.32hex") + ", stale=true" (12)
170 // + NUL is ~161 bytes; MAX_AUTH_LEN + 160 clears that with margin. (A truncated WWW-Authenticate
171 // would be a malformed challenge that breaks Digest auth - a real, if narrow, defect.)
172 char challenge[MAX_AUTH_LEN + 160];
173 if (r->auth_digest)
174 {
175 char nonce[48];
176 make_digest_nonce(nonce, sizeof(nonce)); // a fresh, timestamped nonce per challenge
177 snprintf(challenge, sizeof(challenge),
178 "WWW-Authenticate: Digest realm=\"%s\", qop=\"auth\", algorithm=SHA-256, nonce=\"%s\"%s\r\n",
179 r->auth_realm, nonce, stale ? ", stale=true" : "");
180 }
181 else
182 snprintf(challenge, sizeof(challenge), "WWW-Authenticate: Basic realm=\"%s\"\r\n", r->auth_realm);
183
184 bool keep;
185 const char *cl = resp_conn_hdr(slot_id, &keep);
186
187 static const char body[] = "Unauthorized";
188 char header[RESP_HDR_BUF_SIZE];
189 int hlen = snprintf(header, sizeof(header),
190 "HTTP/1.1 401 Unauthorized\r\n"
191 "%s"
192 "Content-Type: text/plain\r\n"
193 "Content-Length: %d\r\n"
194 "%s"
195 "%s\r\n",
196 challenge, (int)(sizeof(body) - 1), _cors_enabled ? _cors_header_buf : "", cl);
197
198 // Fold the flush into the final write (one marshal instead of two); 401 challenges are frequent
199 // when a protected route is hammered.
200 if (!req_is_head(slot_id))
201 {
202 det_conn_send(slot_id, header, (u16_t)hlen);
203 det_conn_send_flush(slot_id, body, (u16_t)(sizeof(body) - 1));
204 }
205 else
206 {
207 det_conn_send_flush(slot_id, header, (u16_t)hlen);
208 }
209
210 resp_end(slot_id, 401, (int)(sizeof(body) - 1), keep, /*pre_flushed=*/true);
211}
212
213// Constant-time byte equality: compares all @p len bytes regardless of a mismatch, so a credential
214// compare neither truncates on an embedded NUL (as strcmp would) nor leaks via early-out timing how
215// many leading bytes matched (a Basic-auth password timing side-channel).
216static bool ct_equal(const void *a, const void *b, size_t len)
217{
218 const uint8_t *pa = (const uint8_t *)a;
219 const uint8_t *pb = (const uint8_t *)b;
220 uint8_t diff = 0;
221 for (size_t i = 0; i < len; i++)
222 diff |= (uint8_t)(pa[i] ^ pb[i]);
223 return diff == 0;
224}
225
226bool DetWebServer::check_basic_auth(uint8_t /*slot_id*/, HttpReq *req, const Route *r)
227{
228 const char *auth_hdr = http_get_header(req, "Authorization");
229 if (!auth_hdr || strncmp(auth_hdr, "Basic ", 6) != 0)
230 return false;
231
232 uint8_t decoded[MAX_AUTH_LEN * 2 + 2];
233 // Bound the write to leave room for the null terminator at decoded[n]; an
234 // over-long Authorization value now fails the decode instead of overrunning.
235 size_t n = base64_decode(auth_hdr + 6, decoded, sizeof(decoded) - 1);
236 if (n == 0)
237 return false;
238 decoded[n] = '\0';
239
240 const char *colon = (const char *)memchr(decoded, ':', n);
241 if (!colon)
242 return false;
243
244 size_t ulen = (size_t)(colon - (const char *)decoded);
245 const char *pass = colon + 1;
246 size_t plen = n - (size_t)(pass - (const char *)decoded); // real password byte length (may hold NULs)
247
248 // Length-bounded, constant-time compare of BOTH fields (never strcmp): an embedded NUL in the decoded
249 // credential must not truncate the submitted password ("pass\0junk" must not equal "pass"), and the
250 // byte compare must run to completion so it does not leak how many leading bytes matched.
251 bool user_ok = (ulen == strnlen(r->auth_user, MAX_AUTH_LEN)) && ct_equal(decoded, r->auth_user, ulen);
252 bool pass_ok = (plen == strnlen(r->auth_pass, MAX_AUTH_LEN)) && ct_equal(pass, r->auth_pass, plen);
253 return user_ok && pass_ok;
254}
255
256// Validate an Authorization: Digest header (RFC 7616, SHA-256, qop=auth).
257// HA1 = SHA256(user:realm:pass), HA2 = SHA256(method:uri),
258// response = SHA256(HA1:nonce:nc:cnonce:qop:HA2).
259bool DetWebServer::check_digest_auth(uint8_t /*slot_id*/, HttpReq *req, const Route *r, bool *stale)
260{
261 // Use the full-length Authorization capture (the scratch header value is
262 // capped at MAX_VAL_LEN, far shorter than a Digest header).
263 const char *hdr = req->authorization;
264 if (strncmp(hdr, "Digest ", 7) != 0)
265 return false;
266 const char *d = hdr + 7;
267
268 char username[MAX_AUTH_LEN];
269 char nonce[48];
270 char uri[MAX_PATH_LEN + MAX_QUERY_LEN + 2];
271 char qop[16];
272 char nc[16];
273 char cnonce[64];
274 char response[80];
275
276 if (!digest_field(d, "username", username, sizeof(username)) || !digest_field(d, "nonce", nonce, sizeof(nonce)) ||
277 !digest_field(d, "uri", uri, sizeof(uri)) || !digest_field(d, "qop", qop, sizeof(qop)) ||
278 !digest_field(d, "nc", nc, sizeof(nc)) || !digest_field(d, "cnonce", cnonce, sizeof(cnonce)) ||
279 !digest_field(d, "response", response, sizeof(response)))
280 return false;
281
282 // Identity + challenge binding must match before any hashing.
283 if (strcmp(username, r->auth_user) != 0)
284 return false;
285 // The nonce must be one this server minted (authentic MAC). A stale (expired)
286 // nonce is still authentic - we finish the credential check below and let the
287 // caller reissue with stale=true rather than rejecting outright (RFC 7616 3.3).
288 bool nonce_expired = false;
289 if (!verify_digest_nonce(nonce, &nonce_expired))
290 return false;
291 if (strcmp(qop, "auth") != 0)
292 return false;
293
294 // RFC 7616 3.4: the resource named by the "uri" parameter MUST be the same as the
295 // request target; otherwise a Digest response captured for one route could be
296 // replayed against another route under the same realm/nonce.
297 char target[MAX_PATH_LEN + MAX_QUERY_LEN + 2];
298 if (req->query[0])
299 snprintf(target, sizeof(target), "%s?%s", req->path, req->query);
300 else
301 snprintf(target, sizeof(target), "%s", req->path);
302 if (strcmp(uri, target) != 0)
303 return false;
304
305 char tmp[3 * MAX_AUTH_LEN + 4];
306 char ha1[65];
307 char ha2[65];
308 char expected[65];
309
310 int n = snprintf(tmp, sizeof(tmp), "%s:%s:%s", r->auth_user, r->auth_realm, r->auth_pass);
311 sha256_hex((const uint8_t *)tmp, (size_t)n, ha1);
312
313 char tmp2[sizeof(uri) + 16];
314 n = snprintf(tmp2, sizeof(tmp2), "%s:%s", req->method, uri);
315 sha256_hex((const uint8_t *)tmp2, (size_t)n, ha2);
316
317 char tmp3[65 + 48 + 16 + 64 + 8 + 65 + 8];
318 n = snprintf(tmp3, sizeof(tmp3), "%s:%s:%s:%s:%s:%s", ha1, nonce, nc, cnonce, qop, ha2);
319 sha256_hex((const uint8_t *)tmp3, (size_t)n, expected);
320
321 if (strcasecmp(expected, response) != 0)
322 return false; // wrong credentials - leave *stale untouched (no transparent retry)
323 if (nonce_expired)
324 {
325 // Correct credentials but an aged nonce: signal a transparent retry so the
326 // client recomputes against a fresh challenge without re-prompting the user.
327 *stale = true;
328 return false;
329 }
330 return true;
331}
332#endif // DETWS_ENABLE_AUTH
#define MAX_QUERY_LEN
Maximum raw query-string length (everything after ?).
#define RESP_HDR_BUF_SIZE
Stack buffer for HTTP response header lines in send() / send_empty() / send_unauth() / serve_file().
#define MAX_PATH_LEN
Maximum URL path length (including leading /).
#define MAX_AUTH_LEN
Maximum username or password length for HTTP Basic Authentication.
#define DETWS_DIGEST_NONCE_LIFETIME_MS
Lifetime of a Digest nonce, in milliseconds (default 5 minutes).
size_t base64_decode(const char *src, uint8_t *dst, size_t dst_cap)
Decode a null-terminated Base64 string.
Definition base64.cpp:65
Base64 encoder/decoder.
Pluggable monotonic clock for all library timing.
uint32_t detws_millis(void)
The library's monotonic time at 1000 Hz (milliseconds).
Definition clock.h:71
bool req_is_head(uint8_t slot_id)
True if the request in slot slot_id used the HEAD method (send headers, no body).
Layer 7 (Application) - public HTTP routing API.
Library-private declarations shared between dwserver.cpp and the src/server/*.cpp request-handler tra...
Tiny no-stdlib hex encode / decode / nibble helpers (one shared copy).
int det_hex_decode(const char *in, size_t hexlen, uint8_t *out, size_t out_cap)
Decode exactly hexlen hex chars into out (hexlen/2 bytes).
Definition hex.h:61
void det_hex_encode(const uint8_t *in, size_t n, char *out, bool upper=false)
Encode n bytes as 2*n hex chars + NUL into out (needs cap >= 2*n+1).
Definition hex.h:46
const char * http_get_header(const HttpReq *req, const char *key)
Look up a header value by name (case-insensitive).
void http_reset(uint8_t slot_id)
Reset the HTTP parser for a connection slot.
void ssh_sha256(const uint8_t *data, size_t len, uint8_t digest[SSH_SHA256_DIGEST_LEN])
One-shot SHA-256: hash len bytes and write the digest.
SHA-256 (FIPS 180-4) - streaming context and one-shot API.
#define SSH_SHA256_DIGEST_LEN
SHA-256 digest length in bytes.
Definition ssh_sha256.h:29
Fully-parsed HTTP/1.1 request.
char query[MAX_QUERY_LEN]
Raw query string (after ?).
char method[DETWS_METHOD_BUF_SIZE]
HTTP method, null-terminated (OPTIONS, or WebDAV methods when enabled).
char path[MAX_PATH_LEN]
URL path, null-terminated; no query string.
Internal route entry stored in the routing table.
Definition dwserver.h:244
bool det_conn_send(uint8_t slot, const void *data, u16_t len)
Send len bytes on connection slot (copies data; TLS-aware).
Definition tcp.cpp:362
bool det_conn_send_flush(uint8_t slot, const void *data, u16_t len)
Send len bytes on slot and flush in a single tcpip_thread round-trip.
Definition tcp.cpp:378
Layer 4 (Transport) - TCP connection pool, ring buffers, and lwIP integration.