From 38819f63537f03a79afa3bcad6a0ab8c6e9d21cb Mon Sep 17 00:00:00 2001 From: David Cozens Date: Wed, 29 Jul 2026 12:08:46 +0100 Subject: [PATCH] feat: present a client certificate for mutual TLS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClientCertChain and ClientKey join the stream config, and the collector moves to 6515. The device now authenticates itself to the collector as well as verifying it. Flash +13,624 B (+76 on the previous stage) RAM +37,740 B (+2,052) mbedTLS peak +15,912 B (37,244 absolute) Log stack +712 B (unchanged) Service +3,800 B (unchanged) Both handles come from the cert store, which has parsed them since boot. This figure is therefore the cost of *using* credentials the device already holds — a device doing server-authenticated TLS only would also have to provision, store and parse a client certificate and key to reach the same place. Neither stack moves: client authentication is another leg of a handshake the service task already had the depth for. The RAM is 2,048 of mbedTLS pool and 4 bytes of element, and the pool is the whole story: proving the device's identity raised the measured peak from 36,092 to 37,228, and peak x 1.5 rounded up to the next KiB takes the pool from 53 to 55 KiB. That resize is the rule, not a repair. The run passes at 53 KiB with 17 KB of the pool still free, so nothing forced it — but the margin exists for fragmentation rather than capacity, and a pool sized to yesterday's peak is how a later stage inherits someone else's headroom. Both credentials must be set: either one NULL disables mTLS and the connection falls back to server authentication only, without failing. That is why the pipeline element reports what was configured rather than what was intended — an element claiming protection the device does not have would be worse than not reporting it, since it is exactly the weakening a collector is watching for. Delivery is the proof. 6515 requires a client certificate, and scripts/smoke-oracle.sh shows it refusing a client that presents none, so a record arriving there is a record whose sender authenticated. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 18 ++++++++---------- app/AppConfig.h | 2 +- app/syslog/Syslog.c | 11 +++++++++-- app/syslog/SyslogPipelineSd.c | 7 +++++-- app/syslog/SyslogPipelineSd.h | 8 ++++++-- measurements/mtls.csv | 13 +++++++++++++ measurements/stages.tsv | 1 + run-report.md | 34 +++++++++++++++++----------------- 8 files changed, 60 insertions(+), 34 deletions(-) create mode 100644 measurements/mtls.csv diff --git a/README.md b/README.md index 509783a..aa4a702 100644 --- a/README.md +++ b/README.md @@ -10,21 +10,18 @@ It builds on a baseline that simulates the sort of device you might be adding th measures itself: see [docs/baseline.md](docs/baseline.md) for what the baseline is, how the figures are made, and how to run it. -## This stage — Private SD-ELEMENT +## This stage — Mutual TLS -RFC 5424 records carry structured data: named groups of key/value pairs a collector parses rather -than pattern-matches out of the message text. Everything used so far has been standard elements, -which mean the same on any device. This stage defines one of our own — which is where a product -says what only it knows, and `app/syslog/SyslogPipelineSd.c` is all it takes to write one. +The device presents its own certificate, and the collector moves to 6515 — the port that requires +one, and refuses any client that cannot produce it. -`logPipeline@32473` reports the protection the record's own path was under: `transport="tls"`, -`atRest="hmac-sha256"`, under our IANA enterprise number so a private name cannot collide with -anyone else's. A collector can confirm that rather than assume it, and alert on a device whose -pipeline has weakened. +Server-authenticated TLS proves the device is talking to the right collector. Mutual TLS also +proves to the collector which device is talking, so a record is attributable to the holder of a +provisioned key rather than to anything that could reach the port. -**Cost above baseline: Flash +13,548 B, RAM +35,688 B.** +**Cost above baseline: Flash +13,624 B, RAM +37,740 B.** @@ -56,6 +53,7 @@ committed as [`run-report.md`](run-report.md), and rewritten by every stage. | TLS | a collector the device authenticates, and records no longer readable on the wire | +13,076 | +35,664 | | HMAC at rest | stored records that cannot be edited undetected, not merely checked for corruption | +13,424 | +35,684 | | Private SD-ELEMENT | a record that states the protection its own log pipeline was under | +13,548 | +35,688 | +| Mutual TLS | a collector that knows which device sent the record, not just that one did | +13,624 | +37,740 | *Deltas are bytes above the baseline, which is itself Flash 350,308 B, RAM 111,192 B.* diff --git a/app/AppConfig.h b/app/AppConfig.h index 1b241d4..ee3b1d5 100644 --- a/app/AppConfig.h +++ b/app/AppConfig.h @@ -30,6 +30,6 @@ * headroom, not spare capacity: buffer_alloc hands out contiguous space, so a * buffer only a little over the peak fails on fragmentation rather than on * capacity. Applied again wherever more is asked of mbedTLS. */ -#define SIMULATED_APP_MBEDTLS_HEAP_BYTES (53 * 1024) +#define SIMULATED_APP_MBEDTLS_HEAP_BYTES (55 * 1024) #endif /* APP_CONFIG_H */ diff --git a/app/syslog/Syslog.c b/app/syslog/Syslog.c index fcc2ab2..fd453fb 100644 --- a/app/syslog/Syslog.c +++ b/app/syslog/Syslog.c @@ -51,7 +51,7 @@ * the resolver numeric-only — no DNS, so no LWIP_DNS and no DNS resolver * component to compile. */ #define SYSLOG_COLLECTOR_HOST "10.0.2.2" -#define SYSLOG_COLLECTOR_PORT ((uint16_t) 6514U) +#define SYSLOG_COLLECTOR_PORT ((uint16_t) 6515U) /* Absorbs records logged while the service task is busy sending. Many devices can * reduce this further: the store holds the backlog, so the ring only has to cover @@ -147,6 +147,11 @@ void Syslog_Start(void) struct SolidSyslogLwipRawTcpStreamConfig tcpConfig = {.Sleep = SyslogSleep}; + /* Both must be set: either one NULL disables mTLS silently, which is why the + * pipeline element reports what was configured rather than what was intended. */ + struct mbedtls_x509_crt* clientChain = DeviceCertStore_ClientChain(); + struct mbedtls_pk_context* clientKey = DeviceCertStore_ClientKey(); + /* ServerName must match the name in the collector's certificate. */ struct SolidSyslogMbedTlsStreamConfig tlsConfig = { .Transport = SolidSyslogLwipRawTcpStream_Create(&tcpConfig), @@ -154,6 +159,8 @@ void Syslog_Start(void) .Rng = DeviceCertStore_Rng(), .CaChain = DeviceCertStore_CaChain(), .ServerName = SYSLOG_COLLECTOR_HOST, + .ClientCertChain = clientChain, + .ClientKey = clientKey, }; /* No EndpointVersion — this collector never moves, so the sender resolves @@ -183,7 +190,7 @@ void Syslog_Start(void) .GetIpAt = SyslogOriginIpAt, }; s_sd[2] = SolidSyslogOriginSd_Create(&originConfig); - s_sd[3] = SyslogPipelineSd_Get(); + s_sd[3] = SyslogPipelineSd_Init((clientChain != NULL) && (clientKey != NULL)); struct SolidSyslogMbedTlsHmacSha256PolicyConfig hmacConfig = {.GetKey = SyslogStoreKey}; diff --git a/app/syslog/SyslogPipelineSd.c b/app/syslog/SyslogPipelineSd.c index 8fbbeba..d572503 100644 --- a/app/syslog/SyslogPipelineSd.c +++ b/app/syslog/SyslogPipelineSd.c @@ -8,6 +8,8 @@ #include "SolidSyslogSdValue.h" #include "SolidSyslogStructuredDataDefinition.h" +static const char* s_transport = "tls"; + /* A non-zero enterprise number is what makes the SD-ID private: _Begin emits * "name@number" for one, a bare IANA "name" for 0. */ static void SyslogPipelineSd_Format(struct SolidSyslogStructuredData* base, struct SolidSyslogSdElement* element) @@ -15,7 +17,7 @@ static void SyslogPipelineSd_Format(struct SolidSyslogStructuredData* base, stru (void) base; SolidSyslogSdElement_Begin(element, "logPipeline", SYSLOG_ENTERPRISE_NUMBER); - SolidSyslogSdValue_String(SolidSyslogSdElement_Param(element, "transport"), "tls"); + SolidSyslogSdValue_String(SolidSyslogSdElement_Param(element, "transport"), s_transport); SolidSyslogSdValue_String(SolidSyslogSdElement_Param(element, "atRest"), "hmac-sha256"); SolidSyslogSdElement_End(element); } @@ -24,7 +26,8 @@ static void SyslogPipelineSd_Format(struct SolidSyslogStructuredData* base, stru * stateless one is a vtable this application owns. */ static struct SolidSyslogStructuredData s_pipelineSd = {SyslogPipelineSd_Format}; -struct SolidSyslogStructuredData* SyslogPipelineSd_Get(void) +struct SolidSyslogStructuredData* SyslogPipelineSd_Init(bool mutualTls) { + s_transport = mutualTls ? "mtls" : "tls"; return &s_pipelineSd; } diff --git a/app/syslog/SyslogPipelineSd.h b/app/syslog/SyslogPipelineSd.h index fb63624..5440b95 100644 --- a/app/syslog/SyslogPipelineSd.h +++ b/app/syslog/SyslogPipelineSd.h @@ -5,9 +5,13 @@ #ifndef APP_SYSLOG_PIPELINE_SD_H #define APP_SYSLOG_PIPELINE_SD_H +#include + struct SolidSyslogStructuredData; -/** The shared instance, for SolidSyslogConfig.Sd. Stateless, so never NULL. */ -struct SolidSyslogStructuredData* SyslogPipelineSd_Get(void); +/** Records what the pipeline was configured with and returns the shared instance, + * for SolidSyslogConfig.Sd. Never NULL. @p mutualTls must reflect the stream + * config, not the intent. */ +struct SolidSyslogStructuredData* SyslogPipelineSd_Init(bool mutualTls); #endif /* APP_SYSLOG_PIPELINE_SD_H */ diff --git a/measurements/mtls.csv b/measurements/mtls.csv new file mode 100644 index 0000000..3ffcc5e --- /dev/null +++ b/measurements/mtls.csv @@ -0,0 +1,13 @@ +# mtls figures (bytes) — captured by scripts/run.sh (CAPTURE=1). +# The device reads measurements/Baseline.csv as its frozen baseline and reports current-minus-Baseline. +flash_text,363280 +flash_data,652 +static_bss,148280 +heap_used,4440 +mbedtls_peak,37244 +mbedtls_free,19076 +lwip_mem_free,7576 +lwip_pbufs_free,13 +stack_log,832 +stack_service,3852 +stack_harness,2848 diff --git a/measurements/stages.tsv b/measurements/stages.tsv index d9db5b2..c58e46e 100644 --- a/measurements/stages.tsv +++ b/measurements/stages.tsv @@ -28,3 +28,4 @@ origin-ip Origin address the device's own address in the record, which a relay o tls TLS a collector the device authenticates, and records no longer readable on the wire hmac HMAC at rest stored records that cannot be edited undetected, not merely checked for corruption pipeline-sd Private SD-ELEMENT a record that states the protection its own log pipeline was under +mtls Mutual TLS a collector that knows which device sent the record, not just that one did diff --git a/run-report.md b/run-report.md index 2653e76..6daa17b 100644 --- a/run-report.md +++ b/run-report.md @@ -1,4 +1,4 @@ -# solid-syslog-example — run (pipeline-sd) +# solid-syslog-example — run (mtls) ## Device (self-measured) @@ -10,14 +10,14 @@ [device] first record logged: yes [report] --- SolidSyslog cost above baseline (simulated existing application) --- [report] key,current,baseline,used_above_baseline -[report] flash_text,363208,349992,13216 -[report] flash_data,648,316,332 -[report] static_bss,146232,110876,35356 +[report] flash_text,363280,349992,13288 +[report] flash_data,652,316,336 +[report] static_bss,148280,110876,37404 [report] heap_used,4440,4440,0 -[report] mbedtls_peak,36096,21332,14764 -[report] mbedtls_free,18176,11436,6740 +[report] mbedtls_peak,37244,21332,15912 +[report] mbedtls_free,19076,11436,7640 [report] lwip_mem_free,7576,7576,0 -[report] lwip_pbufs_free,14,14,0 +[report] lwip_pbufs_free,13,14,-1 [report] stack_log,832,120,712 [report] stack_service,3852,52,3800 [report] stack_harness,2848,2840,8 @@ -29,7 +29,7 @@ ```text text data bss dec hex filename - 363200 656 146232 510088 7c888 /w/build/baseline-cross/baseline.elf + 363272 660 148280 512212 7d0d4 /w/build/baseline-cross/baseline.elf ``` ## Listeners (proved before the device ran) @@ -47,21 +47,21 @@ ## Collector (syslog-ng) received ```text -wire <134>1 2026-07-29T10:57:51.850000Z 10.0.2.15 solid-syslog-example - BOOT [meta sequenceId="1" sysUpTime="385"][timeQuality tzKnown="1" isSynced="0"][origin software="solid-syslog-example" swVersion="0.1.0" enterpriseId="32473" ip="10.0.2.15"][logPipeline@32473 transport="tls" atRest="hmac-sha256"] device started -parsed PRIORITY=134 TIMESTAMP=2026-07-29T10:57:51+00:00 HOSTNAME=10.0.2.15 APP_NAME=solid-syslog-example PROCID= MSGID=BOOT STRUCTURED_DATA=[meta sequenceId="1" sysUpTime="385"][timeQuality tzKnown="1" isSynced="0"][origin software="solid-syslog-example" swVersion="0.1.0" enterpriseId="32473" ip="10.0.2.15"][logPipeline@32473 transport="tls" atRest="hmac-sha256"] MSG=device started +wire <134>1 2026-07-29T11:06:52.430000Z 10.0.2.15 solid-syslog-example - BOOT [meta sequenceId="1" sysUpTime="243"][timeQuality tzKnown="1" isSynced="0"][origin software="solid-syslog-example" swVersion="0.1.0" enterpriseId="32473" ip="10.0.2.15"][logPipeline@32473 transport="mtls" atRest="hmac-sha256"] device started +parsed PRIORITY=134 TIMESTAMP=2026-07-29T11:06:52+00:00 HOSTNAME=10.0.2.15 APP_NAME=solid-syslog-example PROCID= MSGID=BOOT STRUCTURED_DATA=[meta sequenceId="1" sysUpTime="243"][timeQuality tzKnown="1" isSynced="0"][origin software="solid-syslog-example" swVersion="0.1.0" enterpriseId="32473" ip="10.0.2.15"][logPipeline@32473 transport="mtls" atRest="hmac-sha256"] MSG=device started ``` -## Self-check (vs measurements/pipeline-sd.csv) +## Self-check (vs measurements/mtls.csv) ```text - OK flash_text: 363208 (expected 363208, Δ0) - OK flash_data: 648 (expected 648, Δ0) - OK static_bss: 146232 (expected 146232, Δ0) + OK flash_text: 363280 (expected 363280, Δ0) + OK flash_data: 652 (expected 652, Δ0) + OK static_bss: 148280 (expected 148280, Δ0) OK heap_used: 4440 (expected 4440, Δ0) - OK mbedtls_peak: 36096 (expected 36096, Δ0) - OK mbedtls_free: 18176 (expected 18176, Δ0) + OK mbedtls_peak: 37244 (expected 37244, Δ0) + OK mbedtls_free: 19076 (expected 19076, Δ0) OK lwip_mem_free: 7576 (expected 7576, Δ0) - OK lwip_pbufs_free: 14 (expected 14, Δ0) + OK lwip_pbufs_free: 13 (expected 13, Δ0) OK stack_log: 832 (expected 832, Δ0) OK stack_service: 3852 (expected 3852, Δ0) OK stack_harness: 2848 (expected 2848, Δ0)