Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions DEVLOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
# Dev Log

## 2026-06-04 — S12.28 TLS peer-hostname verification surfaced when ServerName is NULL

Closes the High-severity finding from the pre-0.1.0 security audit: both TLS adapters
(OpenSSL, mbedTLS) bound the peer identity to the certificate only when `ServerName` was
set, so a NULL `ServerName` connected against *any* CA-issued cert — a silent, MITM-class
default with no diagnostic. Now the unverified-peer case is observable.

### Decisions

- **NULL warns, `""` opts out (Model chosen with David).** `ServerName == NULL` emits a
`WARNING` (`BAD_CONFIG` / `*_SERVER_NAME_NOT_SET`) and still connects chain-only,
preserving the closed-network / IP-pinning use case. `ServerName == ""` is the deliberate
opt-out — connect chain-only, no diagnostic. A non-empty name verifies as before.
- **Key insight surfaced in the design discussion: "no DNS" ≠ "no hostname verification".**
Identity binding (`SSL_set1_host` / `mbedtls_ssl_set_hostname`) is a string check against
the cert and needs no DNS. An IP-pinned target can still set `ServerName` to the name (or
IP SAN) on its cert and get full MITM protection. Documented in `integrating-mbedtls.md`
and both `ServerName` header comments.
- **Reused the existing `*_SERVER_NAME_NOT_SET` detail at WARNING severity** rather than
adding a new enum value — the name fits the NULL case and severity is the discriminator
(ERROR = a provided name couldn't be applied → unwound; WARNING = no name → connected
unverified). No public error-enum change.
- TDD across both adapters; a few existing handshake-failure tests had to set a `ServerName`
so the new NULL-warning didn't double-fire alongside their single expected error.

### Deferred

- IP-SAN verification (`X509_VERIFY_PARAM_set1_ip` / mbedTLS equivalent) for IP-literal
targets — `SSL_set1_host` is name-based. A possible follow-up if integrators want verified
IP-pinning rather than the `""` opt-out.

## 2026-06-04 — S21.04 BlockDevice owns its block size

Closes the last open thread under E21 (Port-Time Configurability). Block size moves
Expand Down
5 changes: 4 additions & 1 deletion Platform/MbedTls/Interface/SolidSyslogMbedTlsStream.h
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ EXTERN_C_BEGIN
void* HandshakeTimeoutContext; /* passed through to GetHandshakeTimeoutMs; NULL is fine */
struct mbedtls_ctr_drbg_context* Rng; /* seeded CTR-DRBG — caller owns */
struct mbedtls_x509_crt* CaChain; /* trust anchors — caller owns */
const char* ServerName; /* SNI + cert hostname check; NULL to skip */
const char* ServerName; /* SNI + peer-identity check. A non-empty name is verified against the
cert (SAN/CN). NULL connects chain-only but emits a WARNING — the peer
is unverified (MITM-class). "" is the deliberate opt-out (IP-pinning /
private CA): connect chain-only, no diagnostic. */
struct mbedtls_x509_crt* ClientCertChain; /* leaf (+ intermediates); NULL = no mTLS */
struct mbedtls_pk_context* ClientKey; /* matching private key; NULL = no mTLS */
};
Expand Down
23 changes: 21 additions & 2 deletions Platform/MbedTls/Source/SolidSyslogMbedTlsStream.c
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,22 @@ static inline bool MbedTlsStream_BindContextToConfig(struct SolidSyslogMbedTlsSt
static inline bool MbedTlsStream_ConfigureExpectedHostname(struct SolidSyslogMbedTlsStream* self)
{
bool ok = true;
if (self->Config.ServerName != NULL)
const char* serverName = self->Config.ServerName;
if (serverName == NULL)
{
ok = mbedtls_ssl_set_hostname(&self->SslContext, self->Config.ServerName) == 0;
/* No expected identity supplied — the handshake will accept any cert that
* chains to a trusted CA, so the peer is unverified. Surface it as a
* WARNING (still connect, preserving the IP-pinned / closed-network case)
* rather than swallowing the MITM-class default silently. S12.28. */
MbedTlsStream_Report(
SOLIDSYSLOG_SEVERITY_WARNING,
SOLIDSYSLOG_CAT_BAD_CONFIG,
MBEDTLSSTREAM_ERROR_SERVER_NAME_NOT_SET
);
}
else if (serverName[0] != '\0')
{
ok = mbedtls_ssl_set_hostname(&self->SslContext, serverName) == 0;
if (!ok)
{
MbedTlsStream_Report(
Expand All @@ -203,6 +216,12 @@ static inline bool MbedTlsStream_ConfigureExpectedHostname(struct SolidSyslogMbe
);
}
}
else
{
/* Empty string is the deliberate opt-out: the integrator has no name to
* verify against (IP-pinning / private CA) and has said so explicitly, so
* connect chain-only without a diagnostic. */
}
return ok;
}

Expand Down
5 changes: 4 additions & 1 deletion Platform/OpenSsl/Interface/SolidSyslogTlsStream.h
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ EXTERN_C_BEGIN
GetHandshakeTimeoutMs; /* NULL → use SOLIDSYSLOG_TLS_HANDSHAKE_TIMEOUT_MS tunable */
void* HandshakeTimeoutContext; /* passed through to GetHandshakeTimeoutMs; NULL is fine */
const char* CaBundlePath; /* PEM file of trust anchors */
const char* ServerName; /* SNI + cert hostname check; NULL to skip */
const char* ServerName; /* SNI + peer-identity check. A non-empty name is verified against the
cert (SAN/CN). NULL connects chain-only but emits a WARNING — the peer
is unverified (MITM-class). "" is the deliberate opt-out (IP-pinning /
private CA): connect chain-only, no diagnostic. */
const char* CipherList; /* TLS 1.2 cipher list; NULL = OpenSSL default */
const char* ClientCertChainPath; /* PEM: leaf cert (+ intermediates); NULL = no mTLS */
const char* ClientKeyPath; /* PEM: matching private key; NULL = no mTLS */
Expand Down
20 changes: 17 additions & 3 deletions Platform/OpenSsl/Source/SolidSyslogTlsStream.c
Original file line number Diff line number Diff line change
Expand Up @@ -387,10 +387,18 @@ static inline long TlsStream_TransportBioCtrl(BIO* bio, int cmd, long larg, void
static inline bool TlsStream_ConfigureExpectedHostname(struct SolidSyslogTlsStream* self)
{
bool ok = true;
if (self->Config.ServerName != NULL)
const char* serverName = self->Config.ServerName;
if (serverName == NULL)
{
ok = (SSL_set_tlsext_host_name(self->Ssl, self->Config.ServerName) == 1) &&
(SSL_set1_host(self->Ssl, self->Config.ServerName) == 1);
/* No expected identity supplied — the handshake will accept any cert that
* chains to a trusted CA, so the peer is unverified. Surface it as a
* WARNING (still connect, preserving the IP-pinned / closed-network case)
* rather than swallowing the MITM-class default silently. S12.28. */
TlsStream_Report(SOLIDSYSLOG_SEVERITY_WARNING, SOLIDSYSLOG_CAT_BAD_CONFIG, TLSSTREAM_ERROR_SERVER_NAME_NOT_SET);
}
else if (serverName[0] != '\0')
{
ok = (SSL_set_tlsext_host_name(self->Ssl, serverName) == 1) && (SSL_set1_host(self->Ssl, serverName) == 1);
if (!ok)
{
TlsStream_Report(
Expand All @@ -400,6 +408,12 @@ static inline bool TlsStream_ConfigureExpectedHostname(struct SolidSyslogTlsStre
);
}
}
else
{
/* Empty string is the deliberate opt-out: the integrator has no name to
* verify against (IP-pinning / private CA) and has said so explicitly, so
* connect chain-only without a diagnostic. */
}
return ok;
}

Expand Down
64 changes: 62 additions & 2 deletions Tests/MbedTls/SolidSyslogMbedTlsStreamTest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -274,8 +274,12 @@ TEST(SolidSyslogMbedTlsStream, OpenRetriesHandshakeOnWantWrite)
TEST(SolidSyslogMbedTlsStream, OpenClosesTransportAndFreesSslStateWhenHandshakeBudgetExhausts)

{
/* mbedtls_ssl_handshake always returns WANT_READ — handshake never makes
/* ServerName set so the handshake timeout is the only error source (a NULL
* ServerName would also emit the unverified-peer WARNING).
* mbedtls_ssl_handshake always returns WANT_READ — handshake never makes
* progress, so the bounded budget should expire and Open returns false. */
config.ServerName = "syslog.example.com";
ReCreateHandleWithUpdatedConfig();
ArrangePersistentHandshakeError(MBEDTLS_ERR_SSL_WANT_READ);

CHECK_FALSE(SolidSyslogStream_Open(handle, addr));
Expand Down Expand Up @@ -339,8 +343,11 @@ TEST(SolidSyslogMbedTlsStream, SecondOpenAfterFailedFirstOpenSucceeds)
TEST(SolidSyslogMbedTlsStream, OpenClosesTransportAndFreesSslStateWhenHandshakeFailsHard)

{
/* Non-WANT error (e.g. a verify/connection failure) is fail-fast — no
/* ServerName set so the handshake hard error is the only error source.
* Non-WANT error (e.g. a verify/connection failure) is fail-fast — no
* retry budget burn, no Sleep. */
config.ServerName = "syslog.example.com";
ReCreateHandleWithUpdatedConfig();
ArrangePersistentHandshakeError(MBEDTLS_ERR_SSL_BAD_INPUT_DATA);

CHECK_FALSE(SolidSyslogStream_Open(handle, addr));
Expand Down Expand Up @@ -716,6 +723,59 @@ TEST(SolidSyslogMbedTlsStream, OpenSkipsHostnameWhenServerNameIsNull)
LONGS_EQUAL(0, MbedTlsFake_SslSetHostnameCallCount());
}

TEST(SolidSyslogMbedTlsStream, OpenWarnsWhenServerNameIsNull)

{
/* setup() left config.ServerName at NULL — peer identity is unverified, which
* the library must surface rather than swallow (S12.28). */
SolidSyslogStream_Open(handle, addr);

CALLED_FAKE(ErrorHandlerFake_Handle, ONCE);
POINTERS_EQUAL(&MbedTlsStreamErrorSource, ErrorHandlerFake_LastSource());
UNSIGNED_LONGS_EQUAL(SOLIDSYSLOG_CAT_BAD_CONFIG, ErrorHandlerFake_LastCategory());
UNSIGNED_LONGS_EQUAL(MBEDTLSSTREAM_ERROR_SERVER_NAME_NOT_SET, ErrorHandlerFake_LastDetail());
LONGS_EQUAL(SOLIDSYSLOG_SEVERITY_WARNING, ErrorHandlerFake_LastSeverity());
}

TEST(SolidSyslogMbedTlsStream, OpenStillConnectsWhenServerNameIsNull)

{
/* The unverified-peer WARNING is observable but non-fatal — the IP-pinned /
* closed-network use case must still connect. */
CHECK_TRUE(SolidSyslogStream_Open(handle, addr));
LONGS_EQUAL(0, StreamFake_CloseCallCount(transport));
}

TEST(SolidSyslogMbedTlsStream, OpenDoesNotWarnWhenServerNameIsEmpty)

{
/* Empty string is the deliberate opt-out — no diagnostic. */
config.ServerName = "";
ReCreateHandleWithUpdatedConfig();
SolidSyslogStream_Open(handle, addr);

CALLED_FAKE(ErrorHandlerFake_Handle, NEVER);
}

TEST(SolidSyslogMbedTlsStream, OpenSkipsHostnameSetupWhenServerNameIsEmpty)

{
config.ServerName = "";
ReCreateHandleWithUpdatedConfig();
SolidSyslogStream_Open(handle, addr);

LONGS_EQUAL(0, MbedTlsFake_SslSetHostnameCallCount());
}

TEST(SolidSyslogMbedTlsStream, OpenConnectsWhenServerNameIsEmpty)

{
config.ServerName = "";
ReCreateHandleWithUpdatedConfig();

CHECK_TRUE(SolidSyslogStream_Open(handle, addr));
}

/* -------------------------------------------------------------------------
* mTLS client identity wiring. When the integrator supplies both a
* ClientCertChain and a ClientKey, Open must call mbedtls_ssl_conf_own_cert
Expand Down
64 changes: 59 additions & 5 deletions Tests/SolidSyslogTlsStreamTest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,50 @@ TEST(SolidSyslogTlsStream, OpenSkipsHostnameSetupWhenServerNameIsNull)
POINTERS_EQUAL(NULL, OpenSslFake_LastSet1Host());
}

TEST(SolidSyslogTlsStream, OpenWarnsWhenServerNameIsNull)
{
/* Default config.ServerName is NULL — peer identity is unverified, which the
* library must surface rather than swallow (S12.28). */
SolidSyslogStream_Open(stream, addr);
CALLED_FAKE(ErrorHandlerFake_Handle, ONCE);
POINTERS_EQUAL(&TlsStreamErrorSource, ErrorHandlerFake_LastSource());
UNSIGNED_LONGS_EQUAL(SOLIDSYSLOG_CAT_BAD_CONFIG, ErrorHandlerFake_LastCategory());
UNSIGNED_LONGS_EQUAL(TLSSTREAM_ERROR_SERVER_NAME_NOT_SET, ErrorHandlerFake_LastDetail());
LONGS_EQUAL(SOLIDSYSLOG_SEVERITY_WARNING, ErrorHandlerFake_LastSeverity());
}

TEST(SolidSyslogTlsStream, OpenStillConnectsWhenServerNameIsNull)
{
/* The unverified-peer WARNING is observable but non-fatal — the IP-pinned /
* closed-network use case must still connect. */
CHECK_TRUE(SolidSyslogStream_Open(stream, addr));
LONGS_EQUAL(0, StreamFake_CloseCallCount(transport));
}

TEST(SolidSyslogTlsStream, OpenDoesNotWarnWhenServerNameIsEmpty)
{
/* Empty string is the deliberate opt-out — no diagnostic. */
config.ServerName = "";
ReCreateStreamWithUpdatedConfig();
SolidSyslogStream_Open(stream, addr);
CALLED_FAKE(ErrorHandlerFake_Handle, NEVER);
}

TEST(SolidSyslogTlsStream, OpenSkipsHostnameSetupWhenServerNameIsEmpty)
{
config.ServerName = "";
ReCreateStreamWithUpdatedConfig();
SolidSyslogStream_Open(stream, addr);
POINTERS_EQUAL(NULL, OpenSslFake_LastSet1Host());
}

TEST(SolidSyslogTlsStream, OpenConnectsWhenServerNameIsEmpty)
{
config.ServerName = "";
ReCreateStreamWithUpdatedConfig();
CHECK_TRUE(SolidSyslogStream_Open(stream, addr));
}

TEST(SolidSyslogTlsStream, OpenAttachesTransportAsBioData)
{
SolidSyslogStream_Open(stream, addr);
Expand Down Expand Up @@ -687,9 +731,13 @@ TEST(SolidSyslogTlsStream, OpenReturnsTrueOnHappyPath)

TEST(SolidSyslogTlsStream, OpenReturnsFalseWhenHandshakeFails)
{
/* Default OpenSslFake_SetConnectFails(true) returns -1 from SSL_connect
* and SSL_get_error reports SSL_ERROR_SSL (the default for SetGetErrorReturn)
* — a non-retryable hard error, which is the HANDSHAKE_REJECTED branch. */
/* ServerName set so the handshake stage is the only error source (a NULL
* ServerName would also emit the unverified-peer WARNING). Default
* OpenSslFake_SetConnectFails(true) returns -1 from SSL_connect and
* SSL_get_error reports SSL_ERROR_SSL (the default for SetGetErrorReturn) —
* a non-retryable hard error, which is the HANDSHAKE_REJECTED branch. */
config.ServerName = "logs.example";
ReCreateStreamWithUpdatedConfig();
OpenSslFake_SetConnectFails(true);
OpenSslFake_SetGetErrorReturn(SSL_ERROR_SSL);
CHECK_FALSE(SolidSyslogStream_Open(stream, addr));
Expand Down Expand Up @@ -1158,8 +1206,11 @@ TEST(SolidSyslogTlsStream, OpenRetriesHandshakeOnWantWrite)

TEST(SolidSyslogTlsStream, OpenFailsWhenHandshakeNeverCompletes)
{
/* SSL_connect always returns -1 with WANT_READ — handshake never makes
/* ServerName set so the handshake timeout is the only error source.
SSL_connect always returns -1 with WANT_READ — handshake never makes
progress, so the bounded budget should expire and Open returns false. */
config.ServerName = "logs.example";
ReCreateStreamWithUpdatedConfig();
ArrangePersistentHandshakeError(SSL_ERROR_WANT_READ);
CHECK_FALSE(SolidSyslogStream_Open(stream, addr));
CHECK_OPEN_UNWOUND_WITH_ERROR(
Expand Down Expand Up @@ -1200,7 +1251,10 @@ TEST(SolidSyslogTlsStream, GetterReceivesNullContextWhenContextNotConfigured)

TEST(SolidSyslogTlsStream, OpenFailsImmediatelyOnHardSslError)
{
/* Non-WANT error (e.g. SSL_ERROR_SSL) is fail-fast — no retry budget burn. */
/* ServerName set so the handshake hard error is the only error source.
Non-WANT error (e.g. SSL_ERROR_SSL) is fail-fast — no retry budget burn. */
config.ServerName = "logs.example";
ReCreateStreamWithUpdatedConfig();
ArrangePersistentHandshakeError(SSL_ERROR_SSL);
CHECK_FALSE(SolidSyslogStream_Open(stream, addr));
CALLED_FAKE(OpenSslFake_Connect, ONCE);
Expand Down
2 changes: 1 addition & 1 deletion docs/integrating-mbedtls.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ the per-context mbedTLS handles passed through
| `GetHandshakeTimeoutMs` / `HandshakeTimeoutContext` | You (optional) | Per-instance accessor pair for the bounded handshake budget. `NULL` falls back to the `SOLIDSYSLOG_TLS_HANDSHAKE_TIMEOUT_MS` compile-time tunable (default 5000 ms). Install when you need to runtime-tune the handshake deadline — slow peers on a constrained link, or per-tenant policy from your existing configuration store. The accessor is called on every `Open`. See [S12.17 #282](https://github.com/DavidCozens/solid-syslog/issues/282). |
| `Rng` | You | `mbedtls_ctr_drbg_context*` you seeded yourself. The adapter calls `mbedtls_ctr_drbg_random` against it. Required. |
| `CaChain` | You | `mbedtls_x509_crt*` you parsed yourself (from filesystem, baked-in PEM, HSM, whatever fits your build). Required. |
| `ServerName` | You | SNI + cert hostname check string. `NULL` skips the name check; only appropriate for closed networks where IP-pinning replaces hostname identity. |
| `ServerName` | You | SNI + peer-identity check string. A non-empty name is verified against the server certificate (SAN/CN), rejecting any other CA-issued cert. `NULL` connects but the peer is **unverified** (any cert chaining to a trusted CA is accepted — MITM-class), so the library emits a `WARNING` (`BAD_CONFIG` / `MBEDTLSSTREAM_ERROR_SERVER_NAME_NOT_SET`). Note that having no DNS does **not** force this: an IP-pinned target can still set `ServerName` to the name (or IP SAN) on its certificate and get full identity verification. Use `""` only as a deliberate opt-out for closed networks / private CAs where there is genuinely no name to verify — it connects chain-only with no diagnostic. |
| `ClientCertChain` / `ClientKey` | You | `mbedtls_x509_crt*` + `mbedtls_pk_context*` for mTLS. Both `NULL` = server-auth-only TLS. Both non-`NULL` = mTLS. Supplying only one is treated as "no client cert" — the adapter never half-configures. |

The full struct shape lives in
Expand Down
6 changes: 3 additions & 3 deletions misra_suppressions.txt
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,8 @@ misra-c2012-11.5:Platform/LwipRaw/Source/SolidSyslogLwipRawDnsResolver.c:149
misra-c2012-11.5:Platform/LwipRaw/Source/SolidSyslogLwipRawDnsResolver.c:211
misra-c2012-11.5:Platform/LwipRaw/Source/SolidSyslogLwipRawTcpStream.c:143
misra-c2012-11.5:Platform/LwipRaw/Source/SolidSyslogLwipRawTcpStream.c:151
misra-c2012-11.5:Platform/MbedTls/Source/SolidSyslogMbedTlsStream.c:275
misra-c2012-11.5:Platform/MbedTls/Source/SolidSyslogMbedTlsStream.c:287
misra-c2012-11.5:Platform/MbedTls/Source/SolidSyslogMbedTlsStream.c:294
misra-c2012-11.5:Platform/MbedTls/Source/SolidSyslogMbedTlsStream.c:306
misra-c2012-11.5:Platform/Windows/Source/SolidSyslogWinsockDatagram.c:142
misra-c2012-11.5:Platform/Windows/Source/SolidSyslogWinsockTcpStream.c:357
misra-c2012-11.5:Platform/Windows/Source/SolidSyslogWinsockTcpStream.c:377
Expand Down Expand Up @@ -199,5 +199,5 @@ misra-c2012-8.9:Core/Source/SolidSyslogFileBlockDevice.c:20

# D.013 — Rule 11.5: void* ↔ unsigned char* at third-party byte-buffer API boundaries
# See docs/misra-deviations.md#d013
misra-c2012-11.5:Platform/MbedTls/Source/SolidSyslogMbedTlsStream.c:312
misra-c2012-11.5:Platform/MbedTls/Source/SolidSyslogMbedTlsStream.c:331
misra-c2012-11.5:Platform/MbedTls/Source/SolidSyslogMbedTlsStream.c:350
Loading