ProtoCore v0.0.2
Deterministic, zero-heap network stack for embedded targets
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 PC: Basic (RFC 7617) and stateless Digest
7 * (RFC 7616, SHA-256, qop=auth).
8 *
9 * Split out of protocore.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 (protocore.cpp) calls these PC
12 * methods when a matched route carries auth. Behavior is identical to the pre-split code.
13 */
14
15#include "crypto/hash/sha256.h" // pc_sha256, PC_SHA256_DIGEST_LEN (Digest)
16#include "network_drivers/presentation/codec/base64/base64.h" // pc_base64_decode (Basic)
17#include "network_drivers/transport/tcp.h" // conn_pool, pc_conn_send, TcpConn/ConnState
18#include "protocore.h"
19#include "server/protocore_internal.h" // req_is_head, resp helpers
20#include "services/system/clock.h" // pc_millis() for the stateless nonce
21#include "shared_primitives/hex.h" // pc_hex_encode/decode
22#include "shared_primitives/strbuf.h" // pc_sb frame builder
23#include <stdio.h>
24#include <string.h>
25#if PC_ENABLE_AUTH
26#ifdef ARDUINO
27#include <esp_system.h> // esp_random() for the Digest keying secret
28#endif
29#endif
30// ---------------------------------------------------------------------------
31// Basic Auth helpers
32// ---------------------------------------------------------------------------
33
34#if PC_ENABLE_AUTH
35// One-shot SHA-256 of @p data, written as 64 lowercase hex chars + NUL.
36static void sha256_hex(const uint8_t *data, size_t len, char out[65])
37{
38 uint8_t d[PC_SHA256_DIGEST_LEN];
39 pc_sha256(data, len, d);
41}
42
43// Extract the value of @p key from a Digest auth header into @p out.
44// Handles both quoted ("value") and token (value) forms. The match must sit on
45// a field boundary (start, or after ' '/',') and be immediately followed by '='
46// so "nc" does not match inside "cnonce", etc.
47static bool digest_field(const char *hdr, const char *key, char *out, size_t out_size)
48{
49 size_t klen = strnlen(key, 32);
50 const char *p = hdr;
51 while ((p = strstr(p, key)) != nullptr)
52 {
53 bool left_ok = (p == hdr) || p[-1] == ' ' || p[-1] == ',';
54 const char *after = p + klen;
55 if (!left_ok || *after != '=')
56 {
57 p = after;
58 continue;
59 }
60 after++;
61 const char *vs;
62 const char *ve;
63 if (*after == '"')
64 {
65 vs = after + 1;
66 ve = strchr(vs, '"');
67 if (!ve)
68 {
69 return false;
70 }
71 }
72 else
73 {
74 vs = after;
75 ve = vs;
76 while (*ve && *ve != ',' && *ve != ' ')
77 {
78 ve++;
79 }
80 }
81 size_t vlen = (size_t)(ve - vs);
82 if (vlen > out_size - 1)
83 {
84 vlen = out_size - 1;
85 }
86 memcpy(out, vs, vlen);
87 out[vlen] = '\0';
88 return true;
89 }
90 return false;
91}
92
93void PC::regen_digest_secret()
94{
95 // Seed a 128-bit keying secret from the hardware CSPRNG (esp_random() on
96 // ESP32; a non-crypto mock on native test builds), folded through SHA-256 with
97 // a counter + millis() so even a weak host RNG yields distinct values across
98 // calls. The secret keys every timestamped nonce this server issues; it lives
99 // only in BSS and is never sent on the wire.
100 static uint32_t counter = 0;
101 counter++;
102 uint8_t seed[24];
103 for (int i = 0; i < 4; i++)
104 {
105 uint32_t r = esp_random();
106 memcpy(seed + i * 4, &r, 4);
107 }
108 uint32_t c = counter;
109 uint32_t t = (uint32_t)millis();
110 memcpy(seed + 16, &c, 4);
111 memcpy(seed + 20, &t, 4);
112 uint8_t d[PC_SHA256_DIGEST_LEN];
113 pc_sha256(seed, sizeof(seed), d);
114 memcpy(_digest_secret, d, sizeof(_digest_secret)); // first 128 bits
115}
116
117// Stateless Digest nonce (RFC 7616 3.3): "<issue_ms_hex>.<mac_hex>" where the MAC
118// is SHA-256(secret || issue_ms) truncated to 128 bits. The server holds no
119// per-nonce state - it recomputes the MAC to authenticate a returned nonce and
120// reads the embedded issue time to age it - so the scheme is safe under the
121// shared-nothing worker model (the secret is set once at begin(), read-only after).
122static uint32_t digest_nonce_mac(const uint8_t *secret, uint32_t issue, char *mac_hex)
123{
124 uint8_t material[20];
125 memcpy(material, secret, 16);
126 memcpy(material + 16, &issue, 4); // endian-symmetric: minted and verified the same way
127 uint8_t d[PC_SHA256_DIGEST_LEN];
128 pc_sha256(material, sizeof(material), d);
129 pc_hex_encode(d, 16, mac_hex); // 16 bytes -> 32 hex chars + NUL
130 return issue;
131}
132
133void PC::make_digest_nonce(char *out, size_t cap)
134{
135 uint32_t issue = pc_millis();
136 char issue_hex[9];
137 pc_hex_encode((const uint8_t *)&issue, 4, issue_hex); // 4 bytes -> 8 hex chars
138 char mac_hex[33];
139 digest_nonce_mac(_digest_secret, issue, mac_hex);
140 pc_sb sb_out = {out, cap, 0, true};
141 pc_sb_put(&sb_out, issue_hex);
142 pc_sb_put(&sb_out, ".");
143 pc_sb_put(&sb_out, mac_hex);
144 if (pc_sb_finish(&sb_out) == 0)
145 {
146 out[0] = '\0';
147 }
148}
149
150bool PC::verify_digest_nonce(const char *nonce, bool *expired)
151{
152 *expired = false;
153 // Expected shape: 8 hex (issue) + '.' + 32 hex (MAC).
154 if (strnlen(nonce, 42) != 8 + 1 + 32 || nonce[8] != '.')
155 {
156 return false;
157 }
158 uint32_t issue;
159 if (pc_hex_decode(nonce, 8, (uint8_t *)&issue, 4) != 4)
160 {
161 return false;
162 }
163 char mac_hex[33];
164 digest_nonce_mac(_digest_secret, issue, mac_hex);
165 // Constant-time compare of the 32 MAC hex chars: a forged nonce never reveals
166 // how many leading characters matched.
167 const char *got = nonce + 9;
168 uint8_t diff = 0;
169 for (int i = 0; i < 32; i++)
170 {
171 diff |= (uint8_t)(mac_hex[i] ^ got[i]);
172 }
173 if (diff != 0)
174 {
175 return false; // not a nonce this server minted
176 }
177 uint32_t age = pc_millis() - issue; // unsigned: tolerant of the 32-bit millis wrap
178 *expired = (age > PC_DIGEST_NONCE_LIFETIME_MS);
179 return true;
180}
181
182void PC::send_unauth(uint8_t slot_id, const Route *r, bool stale)
183{
184 if (!pc_conn_active(slot_id))
185 {
186 http_reset(slot_id);
187 return;
188 }
189
190 // Sized for the worst-case Digest challenge without truncation: the fixed field text (~76) + a
191 // max-length realm (MAX_AUTH_LEN-1) + the fixed 41-char nonce ("8hex.32hex") + ", stale=true" (12)
192 // + NUL is ~161 bytes; MAX_AUTH_LEN + 160 clears that with margin. (A truncated WWW-Authenticate
193 // would be a malformed challenge that breaks Digest auth - a real, if narrow, defect.)
194 char challenge[MAX_AUTH_LEN + 160];
195 if (r->auth_digest)
196 {
197 char nonce[48];
198 make_digest_nonce(nonce, sizeof(nonce)); // a fresh, timestamped nonce per challenge
199 pc_sb sb_challenge = {challenge, sizeof(challenge), 0, true};
200 pc_sb_put(&sb_challenge, "WWW-Authenticate: Digest realm=\"");
201 pc_sb_put(&sb_challenge, r->auth_realm);
202 pc_sb_put(&sb_challenge, "\", qop=\"auth\", algorithm=SHA-256, nonce=\"");
203 pc_sb_put(&sb_challenge, nonce);
204 pc_sb_put(&sb_challenge, "\"");
205 pc_sb_put(&sb_challenge, stale ? ", stale=true" : "");
206 pc_sb_put(&sb_challenge, "\r\n");
207 if (pc_sb_finish(&sb_challenge) == 0)
208 {
209 challenge[0] = '\0';
210 }
211 }
212 else
213 {
214 pc_sb sb_challenge2 = {challenge, sizeof(challenge), 0, true};
215 pc_sb_put(&sb_challenge2, "WWW-Authenticate: Basic realm=\"");
216 pc_sb_put(&sb_challenge2, r->auth_realm);
217 pc_sb_put(&sb_challenge2, "\"\r\n");
218 if (pc_sb_finish(&sb_challenge2) == 0)
219 {
220 challenge[0] = '\0';
221 }
222 }
223
224 bool keep;
225 const char *cl = pc_resp_conn_hdr(slot_id, &keep);
226
227 static const char body[] = "Unauthorized";
228 char header[RESP_HDR_BUF_SIZE];
229 pc_sb sb_header = {header, sizeof(header), 0, true};
230 pc_sb_put(&sb_header, "HTTP/1.1 401 Unauthorized\r\n");
231 pc_sb_put(&sb_header, challenge);
232 pc_sb_put(&sb_header, "Content-Type: text/plain\r\nContent-Length: ");
233 pc_sb_i64(&sb_header, (int64_t)((int)(sizeof(body) - 1)));
234 pc_sb_put(&sb_header, "\r\n");
235 pc_sb_put(&sb_header, _cors_enabled ? _cors_header_buf : "");
236 pc_sb_put(&sb_header, cl);
237 pc_sb_put(&sb_header, "\r\n");
238 int hlen = (int)pc_sb_finish(&sb_header);
239
240 // Fold the flush into the final write (one marshal instead of two); 401 challenges are frequent
241 // when a protected route is hammered.
242 if (!req_is_head(slot_id))
243 {
244 pc_conn_send(slot_id, header, (u16_t)hlen);
245 pc_conn_send_flush(slot_id, body, (u16_t)(sizeof(body) - 1));
246 }
247 else
248 {
249 pc_conn_send_flush(slot_id, header, (u16_t)hlen);
250 }
251
252 pc_resp_end(slot_id, 401, (int)(sizeof(body) - 1), keep, /*pre_flushed=*/true);
253}
254
255// Constant-time byte equality: compares all @p len bytes regardless of a mismatch, so a credential
256// compare neither truncates on an embedded NUL (as strcmp would) nor leaks via early-out timing how
257// many leading bytes matched (a Basic-auth password timing side-channel).
258static bool ct_equal(const void *a, const void *b, size_t len)
259{
260 const uint8_t *pa = (const uint8_t *)a;
261 const uint8_t *pb = (const uint8_t *)b;
262 uint8_t diff = 0;
263 for (size_t i = 0; i < len; i++)
264 {
265 diff |= (uint8_t)(pa[i] ^ pb[i]);
266 }
267 return diff == 0;
268}
269
270bool PC::check_basic_auth(uint8_t /*slot_id*/, HttpReq *req, const Route *r)
271{
272 const char *auth_hdr = http_get_header(req, "Authorization");
273 if (!auth_hdr || strncmp(auth_hdr, "Basic ", 6) != 0)
274 {
275 return false;
276 }
277
278 uint8_t decoded[MAX_AUTH_LEN * 2 + 2];
279 // Bound the write to leave room for the null terminator at decoded[n]; an
280 // over-long Authorization value now fails the decode instead of overrunning.
281 size_t n = pc_base64_decode(auth_hdr + 6, decoded, sizeof(decoded) - 1);
282 if (n == 0)
283 {
284 return false;
285 }
286 decoded[n] = '\0';
287
288 const char *colon = (const char *)memchr(decoded, ':', n);
289 if (!colon)
290 {
291 return false;
292 }
293
294 size_t ulen = (size_t)(colon - (const char *)decoded);
295 const char *pass = colon + 1;
296 size_t plen = n - (size_t)(pass - (const char *)decoded); // real password byte length (may hold NULs)
297
298 // Length-bounded, constant-time compare of BOTH fields (never strcmp): an embedded NUL in the decoded
299 // credential must not truncate the submitted password ("pass\0junk" must not equal "pass"), and the
300 // byte compare must run to completion so it does not leak how many leading bytes matched.
301 bool user_ok = (ulen == strnlen(r->auth_user, MAX_AUTH_LEN)) && ct_equal(decoded, r->auth_user, ulen);
302 bool pass_ok = (plen == strnlen(r->auth_pass, MAX_AUTH_LEN)) && ct_equal(pass, r->auth_pass, plen);
303 return user_ok && pass_ok;
304}
305
306// Validate an Authorization: Digest header (RFC 7616, SHA-256, qop=auth).
307// HA1 = SHA256(user:realm:pass), HA2 = SHA256(method:uri),
308// response = SHA256(HA1:nonce:nc:cnonce:qop:HA2).
309bool PC::check_digest_auth(uint8_t /*slot_id*/, HttpReq *req, const Route *r, bool *stale)
310{
311 // Use the full-length Authorization capture (the scratch header value is
312 // capped at MAX_VAL_LEN, far shorter than a Digest header).
313 const char *hdr = req->authorization;
314 if (strncmp(hdr, "Digest ", 7) != 0)
315 {
316 return false;
317 }
318 const char *d = hdr + 7;
319
320 char username[MAX_AUTH_LEN];
321 char nonce[48];
322 char uri[MAX_PATH_LEN + MAX_QUERY_LEN + 2];
323 char qop[16];
324 char nc[16];
325 char cnonce[64];
326 char response[80];
327
328 if (!digest_field(d, "username", username, sizeof(username)) || !digest_field(d, "nonce", nonce, sizeof(nonce)) ||
329 !digest_field(d, "uri", uri, sizeof(uri)) || !digest_field(d, "qop", qop, sizeof(qop)) ||
330 !digest_field(d, "nc", nc, sizeof(nc)) || !digest_field(d, "cnonce", cnonce, sizeof(cnonce)) ||
331 !digest_field(d, "response", response, sizeof(response)))
332 {
333 return false;
334 }
335
336 // Identity + challenge binding must match before any hashing.
337 if (strcmp(username, r->auth_user) != 0)
338 {
339 return false;
340 }
341 // The nonce must be one this server minted (authentic MAC). A stale (expired)
342 // nonce is still authentic - we finish the credential check below and let the
343 // caller reissue with stale=true rather than rejecting outright (RFC 7616 3.3).
344 bool nonce_expired = false;
345 if (!verify_digest_nonce(nonce, &nonce_expired))
346 {
347 return false;
348 }
349 if (strcmp(qop, "auth") != 0)
350 {
351 return false;
352 }
353
354 // RFC 7616 3.4: the resource named by the "uri" parameter MUST be the same as the
355 // request target; otherwise a Digest response captured for one route could be
356 // replayed against another route under the same realm/nonce.
357 char target[MAX_PATH_LEN + MAX_QUERY_LEN + 2];
358 if (req->query[0])
359 {
360 pc_sb sb_target = {target, sizeof(target), 0, true};
361 pc_sb_put(&sb_target, req->path);
362 pc_sb_put(&sb_target, "?");
363 pc_sb_put(&sb_target, req->query);
364 if (pc_sb_finish(&sb_target) == 0)
365 {
366 target[0] = '\0';
367 }
368 }
369 else
370 {
371 pc_sb sb_target2 = {target, sizeof(target), 0, true};
372 pc_sb_put(&sb_target2, req->path);
373 if (pc_sb_finish(&sb_target2) == 0)
374 {
375 target[0] = '\0';
376 }
377 }
378 if (strcmp(uri, target) != 0)
379 {
380 return false;
381 }
382
383 char tmp[3 * MAX_AUTH_LEN + 4];
384 char ha1[65];
385 char ha2[65];
386 char expected[65];
387
388 pc_sb sb_tmp = {tmp, sizeof(tmp), 0, true};
389 pc_sb_put(&sb_tmp, r->auth_user);
390 pc_sb_put(&sb_tmp, ":");
391 pc_sb_put(&sb_tmp, r->auth_realm);
392 pc_sb_put(&sb_tmp, ":");
393 pc_sb_put(&sb_tmp, r->auth_pass);
394 int n = (int)pc_sb_finish(&sb_tmp);
395 sha256_hex((const uint8_t *)tmp, (size_t)n, ha1);
396
397 char tmp2[sizeof(uri) + 16];
398 pc_sb sb_tmp2 = {tmp2, sizeof(tmp2), 0, true};
399 pc_sb_put(&sb_tmp2, req->method);
400 pc_sb_put(&sb_tmp2, ":");
401 pc_sb_put(&sb_tmp2, uri);
402 n = (int)pc_sb_finish(&sb_tmp2);
403 sha256_hex((const uint8_t *)tmp2, (size_t)n, ha2);
404
405 char tmp3[65 + 48 + 16 + 64 + 8 + 65 + 8];
406 pc_sb sb_tmp3 = {tmp3, sizeof(tmp3), 0, true};
407 pc_sb_put(&sb_tmp3, ha1);
408 pc_sb_put(&sb_tmp3, ":");
409 pc_sb_put(&sb_tmp3, nonce);
410 pc_sb_put(&sb_tmp3, ":");
411 pc_sb_put(&sb_tmp3, nc);
412 pc_sb_put(&sb_tmp3, ":");
413 pc_sb_put(&sb_tmp3, cnonce);
414 pc_sb_put(&sb_tmp3, ":");
415 pc_sb_put(&sb_tmp3, qop);
416 pc_sb_put(&sb_tmp3, ":");
417 pc_sb_put(&sb_tmp3, ha2);
418 n = (int)pc_sb_finish(&sb_tmp3);
419 sha256_hex((const uint8_t *)tmp3, (size_t)n, expected);
420
421 if (strcasecmp(expected, response) != 0)
422 {
423 return false; // wrong credentials - leave *stale untouched (no transparent retry)
424 }
425 if (nonce_expired)
426 {
427 // Correct credentials but an aged nonce: signal a transparent retry so the
428 // client recomputes against a fresh challenge without re-prompting the user.
429 *stale = true;
430 return false;
431 }
432 return true;
433}
434#endif // PC_ENABLE_AUTH
size_t pc_base64_decode(const char *src, uint8_t *dst, size_t dst_cap)
Decode a null-terminated Base64 string.
Definition base64.cpp:217
Base64 encoder/decoder.
Pluggable monotonic clock for all library timing.
uint32_t pc_millis(void)
The library's monotonic time at 1000 Hz (milliseconds).
Definition clock.h:71
Base-16 conversion between raw bytes and their ASCII digits.
void pc_hex_encode(const uint8_t *in, uint32_t n, char *out, bool upper=false)
Write n bytes of in into out as 2 * n hex characters plus a NUL.
Definition hex.h:83
int32_t pc_hex_decode(const char *in, uint32_t hexlen, uint8_t *out, uint32_t out_cap)
Read exactly hexlen hex characters from in into out as hexlen / 2 bytes.
Definition hex.h:100
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.
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.
#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 PC_DIGEST_NONCE_LIFETIME_MS
Lifetime of a Digest nonce, in milliseconds (default 5 minutes).
Library-private declarations shared between protocore.cpp and the src/server/*.cpp request-handler tr...
void pc_sha256(const uint8_t *data, size_t len, uint8_t digest[PC_SHA256_DIGEST_LEN])
One-shot SHA-256: hash len bytes of data into digest (32 bytes).
Definition sha256.cpp:58
SHA-256 (FIPS 180-4) - streaming context and one-shot API.
#define PC_SHA256_DIGEST_LEN
SHA-256 digest length in bytes.
Definition sha256.h:25
Bounded no-heap string builder that fails closed on overflow (one shared copy).
size_t pc_sb_finish(pc_sb *b)
NUL-terminate and return the built length, or 0 if the build overflowed.
Definition strbuf.h:648
void pc_sb_i64(pc_sb *b, int64_t v)
Append v as signed decimal (64-bit), with a leading '-' when negative.
Definition strbuf.h:315
void pc_sb_put(pc_sb *b, const char *s)
Append NUL-terminated s; leaves the buffer untouched and clears ok if it would not fit.
Definition strbuf.h:60
Fully-parsed HTTP/1.1 request.
char query[MAX_QUERY_LEN]
Raw query string (after ?).
char path[MAX_PATH_LEN]
URL path, null-terminated; no query string.
char method[PC_METHOD_BUF_SIZE]
HTTP method, null-terminated (OPTIONS, or WebDAV methods when enabled).
Internal route entry stored in the routing table.
Definition protocore.h:244
Bump-append target; ok latches false once an append would overflow cap.
Definition strbuf.h:30
bool pc_conn_send(uint8_t slot, const void *data, u16_t len)
Send len bytes on connection slot (copies data; TLS-aware).
Definition tcp.cpp:500
bool pc_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:518
Layer 4 (Transport) - TCP connection pool, ring buffers, and lwIP integration.