DeterministicESPAsyncWebServer v6.28.0
Zero-allocation, bounded-execution async HTTP server for ESP32
Loading...
Searching...
No Matches
audit_log.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 audit_log.cpp
6 * @brief Hash-chained audit log - implementation.
7 *
8 * Fixed RAM ring of records. Each record's hash chains the previous record's
9 * hash; the oldest retained record chains a moving "anchor" (the hash of the
10 * last evicted record, genesis-zero before any eviction), so the retained window
11 * verifies as a complete chain. SHA-256 comes from ssh_sha256 (HW-accelerated on
12 * ESP32); the timestamp comes from the pluggable det_clock.
13 */
14
17
18#if DETWS_ENABLE_AUDIT_LOG
19
21#include "services/clock.h"
22#include <string.h>
23
24namespace
25{
26// All audit-log state, owned by one instance (internal linkage): the record ring, its
27// head/count/seq cursors, the moving chain anchor, and the sink, grouped so it is one
28// named owner, unreachable from any other translation unit.
29struct AuditCtx
30{
31 DetwsAuditEntry ring[DETWS_AUDIT_LOG_ENTRIES];
32 uint16_t head = 0; // index of the oldest retained record
33 uint16_t count = 0; // records currently retained
34 uint32_t seq = 0; // last assigned sequence number (monotonic)
35 uint8_t anchor[DETWS_AUDIT_HASH_LEN] = {}; // prev-hash for the oldest retained record
36 detws_audit_sink_fn sink = nullptr;
37};
38AuditCtx s_audit;
39
40inline uint16_t idx(const AuditCtx &c, uint16_t i)
41{
42 return (uint16_t)((c.head + i) % DETWS_AUDIT_LOG_ENTRIES);
43}
44
45void put_le32(uint8_t out[4], uint32_t v)
46{
47 out[0] = (uint8_t)(v & 0xFF);
48 out[1] = (uint8_t)((v >> 8) & 0xFF);
49 out[2] = (uint8_t)((v >> 16) & 0xFF);
50 out[3] = (uint8_t)((v >> 24) & 0xFF);
51}
52
53// hash = SHA-256(prev_hash || seq_le || ts_le || category || msg_len || msg).
54// msg_len is length-prefixed so two records can never serialize ambiguously.
55void chain_hash(const uint8_t prev[DETWS_AUDIT_HASH_LEN], const DetwsAuditEntry *e, uint8_t out[DETWS_AUDIT_HASH_LEN])
56{
59 ssh_sha256_update(&c, prev, DETWS_AUDIT_HASH_LEN);
60 uint8_t le[4];
61 put_le32(le, e->seq);
62 ssh_sha256_update(&c, le, 4);
63 put_le32(le, e->ts);
64 ssh_sha256_update(&c, le, 4);
65 ssh_sha256_update(&c, (const uint8_t *)&e->category, 1); // hash the raw category byte
66 uint8_t mlen = (uint8_t)strnlen(e->msg, DETWS_AUDIT_MSG_LEN - 1);
67 ssh_sha256_update(&c, &mlen, 1);
68 ssh_sha256_update(&c, (const uint8_t *)e->msg, mlen);
69 ssh_sha256_final(&c, out);
70}
71
72// Append @p n JSON-escaped bytes of @p s into out[pos..cap); returns new pos, or
73// cap+1 on overflow (caller checks pos <= cap).
74size_t json_escape(char *out, size_t pos, size_t cap, const char *s)
75{
76 for (size_t i = 0; s[i]; i++)
77 {
78 unsigned char ch = (unsigned char)s[i];
79 const char *esc = nullptr;
80 char ub[7];
81 size_t n;
82 if (ch == '"')
83 esc = "\\\"", n = 2;
84 else if (ch == '\\')
85 esc = "\\\\", n = 2;
86 else if (ch == '\n')
87 esc = "\\n", n = 2;
88 else if (ch == '\r')
89 esc = "\\r", n = 2;
90 else if (ch == '\t')
91 esc = "\\t", n = 2;
92 else if (ch < 0x20)
93 {
94 ub[0] = '\\', ub[1] = 'u', ub[2] = '0', ub[3] = '0';
95 ub[4] = det_hex_digit((ch >> 4) & 0xF);
96 ub[5] = det_hex_digit(ch & 0xF);
97 ub[6] = '\0';
98 esc = ub, n = 6;
99 }
100 else
101 {
102 if (pos + 1 > cap)
103 return cap + 1;
104 out[pos++] = (char)ch;
105 continue;
106 }
107 if (pos + n > cap)
108 return cap + 1;
109 memcpy(out + pos, esc, n);
110 pos += n;
111 }
112 return pos;
113}
114
115size_t hex_hash(char *out, size_t pos, size_t cap, const uint8_t *h)
116{
117 if (pos + DETWS_AUDIT_HASH_LEN * 2 > cap)
118 return cap + 1;
119 for (size_t i = 0; i < DETWS_AUDIT_HASH_LEN; i++)
120 {
121 out[pos++] = det_hex_digit((h[i] >> 4) & 0xF);
122 out[pos++] = det_hex_digit(h[i] & 0xF);
123 }
124 return pos;
125}
126} // namespace
127
128void detws_audit_reset(void)
129{
130 s_audit.head = 0;
131 s_audit.count = 0;
132 s_audit.seq = 0;
133 memset(s_audit.anchor, 0, sizeof(s_audit.anchor)); // genesis
134}
135
136void detws_audit_set_sink(detws_audit_sink_fn sink)
137{
138 s_audit.sink = sink;
139}
140
141uint32_t detws_audit_append(DetwsAuditCat category, const char *msg)
142{
143 // prev = hash of the current newest record (anchor if the ring is empty).
144 uint8_t prev[DETWS_AUDIT_HASH_LEN];
145 if (s_audit.count == 0)
146 memcpy(prev, s_audit.anchor, DETWS_AUDIT_HASH_LEN);
147 else
148 memcpy(prev, s_audit.ring[idx(s_audit, (uint16_t)(s_audit.count - 1))].hash, DETWS_AUDIT_HASH_LEN);
149
150 // Full ring: evict the oldest; its hash advances the chain anchor so the
151 // retained window still verifies. (Eviction touches only the oldest, never
152 // the newest we just read as prev.)
153 if (s_audit.count == DETWS_AUDIT_LOG_ENTRIES)
154 {
155 memcpy(s_audit.anchor, s_audit.ring[s_audit.head].hash, DETWS_AUDIT_HASH_LEN);
156 s_audit.head = (uint16_t)((s_audit.head + 1) % DETWS_AUDIT_LOG_ENTRIES);
157 s_audit.count--;
158 }
159
160 DetwsAuditEntry *e = &s_audit.ring[idx(s_audit, s_audit.count)];
161 e->seq = ++s_audit.seq;
162 e->ts = detws_millis();
163 e->category = category;
164 if (msg)
165 {
166 size_t n = strnlen(msg, DETWS_AUDIT_MSG_LEN - 1);
167 memcpy(e->msg, msg, n);
168 e->msg[n] = '\0';
169 }
170 else
171 e->msg[0] = '\0';
172 chain_hash(prev, e, e->hash);
173 s_audit.count++;
174
175 if (s_audit.sink)
176 s_audit.sink(e);
177 return e->seq;
178}
179
180uint16_t detws_audit_count(void)
181{
182 return s_audit.count;
183}
184
185const DetwsAuditEntry *detws_audit_at(uint16_t i)
186{
187 if (i >= s_audit.count)
188 return nullptr;
189 return &s_audit.ring[idx(s_audit, i)];
190}
191
192bool detws_audit_verify(uint32_t *first_broken_seq)
193{
194 uint8_t expected[DETWS_AUDIT_HASH_LEN];
195 memcpy(expected, s_audit.anchor, DETWS_AUDIT_HASH_LEN);
196 for (uint16_t i = 0; i < s_audit.count; i++)
197 {
198 const DetwsAuditEntry *e = &s_audit.ring[idx(s_audit, i)];
199 uint8_t h[DETWS_AUDIT_HASH_LEN];
200 chain_hash(expected, e, h);
201 if (memcmp(h, e->hash, DETWS_AUDIT_HASH_LEN) != 0)
202 {
203 if (first_broken_seq)
204 *first_broken_seq = e->seq;
205 return false;
206 }
207 memcpy(expected, e->hash, DETWS_AUDIT_HASH_LEN);
208 }
209 return true;
210}
211
212const char *detws_audit_cat_name(DetwsAuditCat category)
213{
214 switch (category)
215 {
216 case DetwsAuditCat::DETWS_AUDIT_AUTH:
217 return "auth";
218 case DetwsAuditCat::DETWS_AUDIT_AUTH_FAIL:
219 return "auth_fail";
220 case DetwsAuditCat::DETWS_AUDIT_ACCESS:
221 return "access";
222 case DetwsAuditCat::DETWS_AUDIT_CONFIG:
223 return "config";
224 case DetwsAuditCat::DETWS_AUDIT_ADMIN:
225 return "admin";
226 default:
227 return "system";
228 }
229}
230
231int detws_audit_format(const DetwsAuditEntry *e, char *out, size_t cap)
232{
233 if (!e || !out || cap == 0)
234 return 0;
235 int head = snprintf(out, cap, "{\"seq\":%lu,\"ts\":%lu,\"cat\":\"%s\",\"msg\":\"", (unsigned long)e->seq,
236 (unsigned long)e->ts, detws_audit_cat_name(e->category));
237 if (head < 0 || (size_t)head >= cap)
238 return 0;
239 size_t pos = (size_t)head;
240 pos = json_escape(out, pos, cap, e->msg);
241 if (pos > cap)
242 return 0;
243 static const char mid[] = "\",\"hash\":\"";
244 size_t mid_len = sizeof(mid) - 1;
245 if (pos + mid_len > cap)
246 return 0;
247 memcpy(out + pos, mid, mid_len);
248 pos += mid_len;
249 pos = hex_hash(out, pos, cap, e->hash);
250 if (pos > cap)
251 return 0;
252 if (pos + 2 > cap) // closing "} plus NUL space
253 return 0;
254 out[pos++] = '"';
255 out[pos++] = '}';
256 if (pos >= cap)
257 return 0;
258 out[pos] = '\0';
259 return (int)pos;
260}
261
262int detws_audit_dump_json(char *out, size_t cap)
263{
264 if (!out || cap == 0)
265 return 0;
266 uint32_t broken = 0;
267 bool intact = detws_audit_verify(&broken);
268
269 int head;
270 if (intact)
271 head = snprintf(out, cap, "{\"intact\":true,\"count\":%u,\"entries\":[", (unsigned)s_audit.count);
272 else
273 head = snprintf(out, cap, "{\"intact\":false,\"first_broken\":%lu,\"count\":%u,\"entries\":[",
274 (unsigned long)broken, (unsigned)s_audit.count);
275 if (head < 0 || (size_t)head >= cap)
276 return 0;
277 size_t pos = (size_t)head;
278
279 for (uint16_t i = 0; i < s_audit.count; i++)
280 {
281 if (i > 0)
282 {
283 if (pos + 1 > cap)
284 return 0;
285 out[pos++] = ',';
286 }
287 int n = detws_audit_format(&s_audit.ring[idx(s_audit, i)], out + pos, cap - pos);
288 if (n <= 0)
289 return 0;
290 pos += (size_t)n;
291 }
292 if (pos + 2 > cap)
293 return 0;
294 out[pos++] = ']';
295 out[pos++] = '}';
296 if (pos >= cap)
297 return 0;
298 out[pos] = '\0';
299 return (int)pos;
300}
301
302#endif // DETWS_ENABLE_AUDIT_LOG
Tamper-evident, hash-chained audit log (DETWS_ENABLE_AUDIT_LOG).
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
Tiny no-stdlib hex encode / decode / nibble helpers (one shared copy).
char det_hex_digit(int nibble, bool upper=false)
Nibble 0..15 -> hex char (lowercase, or uppercase when upper).
Definition hex.h:24
void ssh_sha256_init(SshSha256Ctx *ctx)
Initialize a streaming SHA-256 context.
void ssh_sha256_update(SshSha256Ctx *ctx, const uint8_t *data, size_t len)
Feed len bytes of data into the running hash.
void ssh_sha256_final(SshSha256Ctx *ctx, uint8_t digest[SSH_SHA256_DIGEST_LEN])
Finalize the hash and write the 32-byte digest.
SHA-256 (FIPS 180-4) - streaming context and one-shot API.
Streaming SHA-256 context.
Definition ssh_sha256.h:44