ProtoCore v0.0.2
Deterministic, zero-heap network stack for embedded targets
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 pc_sha256 (HW-accelerated on
12 * ESP32); the timestamp comes from the pluggable pc_clock.
13 */
14
17#include "shared_primitives/strbuf.h" // pc_sb frame builder
18
19#if PC_ENABLE_AUDIT_LOG
20
21#include "crypto/hash/sha256.h"
23#include <string.h>
24
25namespace
26{
27// All audit-log state, owned by one instance (internal linkage): the record ring, its
28// head/count/seq cursors, the moving chain anchor, and the sink, grouped so it is one
29// named owner, unreachable from any other translation unit.
30struct AuditCtx
31{
32 pc_audit_entry ring[PC_AUDIT_LOG_ENTRIES];
33 uint16_t head = 0; // index of the oldest retained record
34 uint16_t count = 0; // records currently retained
35 uint32_t seq = 0; // last assigned sequence number (monotonic)
36 uint8_t anchor[PC_AUDIT_HASH_LEN] = {}; // prev-hash for the oldest retained record
37 pc_audit_sink_fn sink = nullptr;
38};
39AuditCtx s_audit;
40
41inline uint16_t idx(const AuditCtx &c, uint16_t i)
42{
43 return (uint16_t)((c.head + i) % PC_AUDIT_LOG_ENTRIES);
44}
45
46void put_le32(uint8_t out[4], uint32_t v)
47{
48 out[0] = (uint8_t)(v & 0xFF);
49 out[1] = (uint8_t)((v >> 8) & 0xFF);
50 out[2] = (uint8_t)((v >> 16) & 0xFF);
51 out[3] = (uint8_t)((v >> 24) & 0xFF);
52}
53
54// hash = SHA-256(prev_hash || seq_le || ts_le || category || msg_len || msg).
55// msg_len is length-prefixed so two records can never serialize ambiguously.
56void chain_hash(const uint8_t prev[PC_AUDIT_HASH_LEN], const pc_audit_entry *e, uint8_t out[PC_AUDIT_HASH_LEN])
57{
60 pc_sha256_update(&c, prev, PC_AUDIT_HASH_LEN);
61 uint8_t le[4];
62 put_le32(le, e->seq);
63 pc_sha256_update(&c, le, 4);
64 put_le32(le, e->ts);
65 pc_sha256_update(&c, le, 4);
66 pc_sha256_update(&c, (const uint8_t *)&e->category, 1); // hash the raw category byte
67 uint8_t mlen = (uint8_t)strnlen(e->msg, PC_AUDIT_MSG_LEN - 1);
68 pc_sha256_update(&c, &mlen, 1);
69 pc_sha256_update(&c, (const uint8_t *)e->msg, mlen);
70 pc_sha256_final(&c, out);
71}
72
73// Append @p n JSON-escaped bytes of @p s into out[pos..cap); returns new pos, or
74// cap+1 on overflow (caller checks pos <= cap).
75size_t json_escape(char *out, size_t pos, size_t cap, const char *s)
76{
77 for (size_t i = 0; s[i]; i++)
78 {
79 unsigned char ch = (unsigned char)s[i];
80 const char *esc = nullptr;
81 char ub[7];
82 size_t n;
83 if (ch == '"')
84 {
85 esc = "\\\"", n = 2;
86 }
87 else if (ch == '\\')
88 {
89 esc = "\\\\", n = 2;
90 }
91 else if (ch == '\n')
92 {
93 esc = "\\n", n = 2;
94 }
95 else if (ch == '\r')
96 {
97 esc = "\\r", n = 2;
98 }
99 else if (ch == '\t')
100 {
101 esc = "\\t", n = 2;
102 }
103 else if (ch < 0x20)
104 {
105 ub[0] = '\\', ub[1] = 'u', ub[2] = '0', ub[3] = '0';
106 ub[4] = pc_hex_digit((ch >> 4) & 0xF);
107 ub[5] = pc_hex_digit(ch & 0xF);
108 ub[6] = '\0';
109 esc = ub, n = 6;
110 }
111 else
112 {
113 if (pos + 1 > cap)
114 {
115 return cap + 1;
116 }
117 out[pos++] = (char)ch;
118 continue;
119 }
120 if (pos + n > cap)
121 {
122 return cap + 1;
123 }
124 memcpy(out + pos, esc, n);
125 pos += n;
126 }
127 return pos;
128}
129
130size_t hex_hash(char *out, size_t pos, size_t cap, const uint8_t *h)
131{
132 if (pos + PC_AUDIT_HASH_LEN * 2 > cap)
133 {
134 return cap + 1;
135 }
136 for (size_t i = 0; i < PC_AUDIT_HASH_LEN; i++)
137 {
138 out[pos++] = pc_hex_digit((h[i] >> 4) & 0xF);
139 out[pos++] = pc_hex_digit(h[i] & 0xF);
140 }
141 return pos;
142}
143} // namespace
144
145void pc_audit_reset(void)
146{
147 s_audit.head = 0;
148 s_audit.count = 0;
149 s_audit.seq = 0;
150 memset(s_audit.anchor, 0, sizeof(s_audit.anchor)); // genesis
151}
152
153void pc_audit_set_sink(pc_audit_sink_fn sink)
154{
155 s_audit.sink = sink;
156}
157
158uint32_t pc_audit_append(pc_audit_cat category, const char *msg)
159{
160 // prev = hash of the current newest record (anchor if the ring is empty).
161 uint8_t prev[PC_AUDIT_HASH_LEN];
162 if (s_audit.count == 0)
163 {
164 memcpy(prev, s_audit.anchor, PC_AUDIT_HASH_LEN);
165 }
166 else
167 {
168 memcpy(prev, s_audit.ring[idx(s_audit, (uint16_t)(s_audit.count - 1))].hash, PC_AUDIT_HASH_LEN);
169 }
170
171 // Full ring: evict the oldest; its hash advances the chain anchor so the
172 // retained window still verifies. (Eviction touches only the oldest, never
173 // the newest we just read as prev.)
174 if (s_audit.count == PC_AUDIT_LOG_ENTRIES)
175 {
176 memcpy(s_audit.anchor, s_audit.ring[s_audit.head].hash, PC_AUDIT_HASH_LEN);
177 s_audit.head = (uint16_t)((s_audit.head + 1) % PC_AUDIT_LOG_ENTRIES);
178 s_audit.count--;
179 }
180
181 pc_audit_entry *e = &s_audit.ring[idx(s_audit, s_audit.count)];
182 e->seq = ++s_audit.seq;
183 e->ts = pc_millis();
184 e->category = category;
185 if (msg)
186 {
187 size_t n = strnlen(msg, PC_AUDIT_MSG_LEN - 1);
188 memcpy(e->msg, msg, n);
189 e->msg[n] = '\0';
190 }
191 else
192 {
193 e->msg[0] = '\0';
194 }
195 chain_hash(prev, e, e->hash);
196 s_audit.count++;
197
198 if (s_audit.sink)
199 {
200 s_audit.sink(e);
201 }
202 return e->seq;
203}
204
205uint16_t pc_audit_count(void)
206{
207 return s_audit.count;
208}
209
210const pc_audit_entry *pc_audit_at(uint16_t i)
211{
212 if (i >= s_audit.count)
213 {
214 return nullptr;
215 }
216 return &s_audit.ring[idx(s_audit, i)];
217}
218
219bool pc_audit_verify(uint32_t *first_broken_seq)
220{
221 uint8_t expected[PC_AUDIT_HASH_LEN];
222 memcpy(expected, s_audit.anchor, PC_AUDIT_HASH_LEN);
223 for (uint16_t i = 0; i < s_audit.count; i++)
224 {
225 const pc_audit_entry *e = &s_audit.ring[idx(s_audit, i)];
226 uint8_t h[PC_AUDIT_HASH_LEN];
227 chain_hash(expected, e, h);
228 if (memcmp(h, e->hash, PC_AUDIT_HASH_LEN) != 0)
229 {
230 if (first_broken_seq)
231 {
232 *first_broken_seq = e->seq;
233 }
234 return false;
235 }
236 memcpy(expected, e->hash, PC_AUDIT_HASH_LEN);
237 }
238 return true;
239}
240
241const char *pc_audit_cat_name(pc_audit_cat category)
242{
243 switch (category)
244 {
245 case pc_audit_cat::PC_AUDIT_AUTH:
246 return "auth";
247 case pc_audit_cat::PC_AUDIT_AUTH_FAIL:
248 return "auth_fail";
249 case pc_audit_cat::PC_AUDIT_ACCESS:
250 return "access";
251 case pc_audit_cat::PC_AUDIT_CONFIG:
252 return "config";
253 case pc_audit_cat::PC_AUDIT_ADMIN:
254 return "admin";
255 default:
256 return "system";
257 }
258}
259
260int pc_audit_format(const pc_audit_entry *e, char *out, size_t cap)
261{
262 if (!e || !out || cap == 0)
263 {
264 return 0;
265 }
266 pc_sb sb_out = {out, cap, 0, true};
267 pc_sb_put(&sb_out, "{\"seq\":");
268 pc_sb_u32(&sb_out, (uint32_t)((unsigned long)e->seq));
269 pc_sb_put(&sb_out, ",\"ts\":");
270 pc_sb_u32(&sb_out, (uint32_t)((unsigned long)e->ts));
271 pc_sb_put(&sb_out, ",\"cat\":\"");
272 pc_sb_put(&sb_out, pc_audit_cat_name(e->category));
273 pc_sb_put(&sb_out, "\",\"msg\":\"");
274 int head = (int)pc_sb_finish(&sb_out);
275 // Only head<0 is unreachable here: this format is fixed ("%lu%lu%s...", no floating point,
276 // no wide chars) with always-valid args (pc_audit_cat_name never returns null; the produced
277 // text is bounded by PC_AUDIT_MSG_LEN/PC_AUDIT_HASH_LEN, both small fixed constants, so the
278 // result can never approach INT_MAX either) - snprintf cannot signal an encoding error for it.
279 // The (size_t)head>=cap arm IS reachable and tested (test_format_fails_closed_all_stages's cap
280 // sweep); gcovr has no sub-line exclusion granularity, so silencing the dead head<0 arm means
281 // excluding the whole line's branch data.
282 if (head < 0 || (size_t)head >= cap) // GCOVR_EXCL_BR_LINE
283 {
284 return 0;
285 }
286 size_t pos = (size_t)head;
287 pos = json_escape(out, pos, cap, e->msg);
288 if (pos > cap)
289 {
290 return 0;
291 }
292 static const char mid[] = "\",\"hash\":\"";
293 size_t mid_len = sizeof(mid) - 1;
294 if (pos + mid_len > cap)
295 {
296 return 0;
297 }
298 memcpy(out + pos, mid, mid_len);
299 pos += mid_len;
300 pos = hex_hash(out, pos, cap, e->hash);
301 if (pos > cap)
302 {
303 return 0;
304 }
305 if (pos + 2 > cap) // closing "} plus NUL space
306 {
307 return 0;
308 }
309 out[pos++] = '"';
310 out[pos++] = '}';
311 if (pos >= cap)
312 {
313 return 0;
314 }
315 out[pos] = '\0';
316 return (int)pos;
317}
318
319int pc_audit_dump_json(char *out, size_t cap)
320{
321 if (!out || cap == 0)
322 {
323 return 0;
324 }
325 uint32_t broken = 0;
326 bool intact = pc_audit_verify(&broken);
327
328 int head;
329 if (intact)
330 {
331 pc_sb sb_out2 = {out, cap, 0, true};
332 pc_sb_put(&sb_out2, "{\"intact\":true,\"count\":");
333 pc_sb_u32(&sb_out2, (uint32_t)((unsigned)s_audit.count));
334 pc_sb_put(&sb_out2, ",\"entries\":[");
335 head = (int)pc_sb_finish(&sb_out2);
336 }
337 else
338 {
339 pc_sb sb_out3 = {out, cap, 0, true};
340 pc_sb_put(&sb_out3, "{\"intact\":false,\"first_broken\":");
341 pc_sb_u32(&sb_out3, (uint32_t)((unsigned long)broken));
342 pc_sb_put(&sb_out3, ",\"count\":");
343 pc_sb_u32(&sb_out3, (uint32_t)((unsigned)s_audit.count));
344 pc_sb_put(&sb_out3, ",\"entries\":[");
345 head = (int)pc_sb_finish(&sb_out3);
346 }
347 // Only head<0 is unreachable here, for the same reason as pc_audit_format's identical guard:
348 // both snprintf formats above are fixed ("%s...:[" with %u/%lu, no floating point, no wide
349 // chars), the args are always valid, and the produced text is bounded by s_audit.count's small
350 // fixed max (PC_AUDIT_LOG_ENTRIES), so it can never approach INT_MAX. The (size_t)head>=cap
351 // arm IS reachable and tested (test_dump_fails_closed_all_stages's cap sweep); gcovr has no
352 // sub-line exclusion granularity, so silencing the dead head<0 arm means excluding the whole
353 // line's branch data.
354 if (head < 0 || (size_t)head >= cap) // GCOVR_EXCL_BR_LINE
355 {
356 return 0;
357 }
358 size_t pos = (size_t)head;
359
360 for (uint16_t i = 0; i < s_audit.count; i++)
361 {
362 if (i > 0)
363 {
364 // GCOVR_EXCL_START unreachable: reaching here means entry i-1 (or the header, for i==1)
365 // rendered successfully, and its own overflow check (pc_audit_format's or snprintf's)
366 // required strictly less than the cap it was given - so cap - pos is always >= 1 at this
367 // point, exactly the one byte this comma needs. The guard can never fire.
368 if (pos + 1 > cap)
369 {
370 return 0;
371 }
372 // GCOVR_EXCL_STOP
373 out[pos++] = ',';
374 }
375 int n = pc_audit_format(&s_audit.ring[idx(s_audit, i)], out + pos, cap - pos);
376 if (n <= 0)
377 {
378 return 0;
379 }
380 pos += (size_t)n;
381 }
382 if (pos + 2 > cap)
383 {
384 return 0;
385 }
386 out[pos++] = ']';
387 out[pos++] = '}';
388 if (pos >= cap)
389 {
390 return 0;
391 }
392 out[pos] = '\0';
393 return (int)pos;
394}
395
396#endif // PC_ENABLE_AUDIT_LOG
Tamper-evident, hash-chained audit log (PC_ENABLE_AUDIT_LOG).
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.
char pc_hex_digit(uint8_t nibble, bool upper=false)
Low nibble of nibble as a hex character, uppercase when upper.
Definition hex.h:30
PC_CRYPTO_HOT void pc_sha256_init(pc_sha256_ctx *ctx)
Initialize a streaming SHA-256 context (ctx must not be NULL).
Definition sha256.cpp:29
void pc_sha256_final(pc_sha256_ctx *ctx, uint8_t digest[PC_SHA256_DIGEST_LEN])
Finalize the hash and write the 32-byte digest. The context is undefined afterwards; call init() agai...
Definition sha256.cpp:48
void pc_sha256_update(pc_sha256_ctx *ctx, const uint8_t *data, size_t len)
Feed len bytes of data into the running hash.
Definition sha256.cpp:39
SHA-256 (FIPS 180-4) - streaming context and one-shot API.
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_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
void pc_sb_u32(pc_sb *b, uint32_t v)
Append v as decimal (no leading zeros; "0" for zero).
Definition strbuf.h:303
Bump-append target; ok latches false once an append would overflow cap.
Definition strbuf.h:30
Streaming SHA-256 context.
Definition sha256.h:40