Overview
Eight of the issues on the 0.2.0 milestone concern TLS — #718, #719, #731, #732,
#733, #734, #735 and #753. They arrived separately, from the S23.22 documentation
audit and from writing the TLS contract, and were filed as independent defects.
Read together against the code and the RFC text they are not independent, and
three of them were blocked on decisions nobody had taken.
This epic takes those decisions once, sequences the work against them, and
carries the one deliberate API break that comes with them.
The outcome it is for: RFC 5425 §5.1 moves off Not Met, the two shipped TLS
adapters stop diverging from each other, where credentials come from becomes the
integrator's choice rather than ours, and docs/tls.md says what the code does.
Three findings that drove the design
docs/tls.md cannot be satisfied as written. Its trust-anchor obligation — "a
Stream that cannot load them fails to open" — contradicts the fingerprint
obligation on the same page, because RFC 5425 §4.2.1 says an end-entity
certificate matched by fingerprint "can be self-signed, and no certification path
validation is needed". One of the two has to give.
The page also argues with itself about certificate validity. It lists exactly
three things that stop delivery — no trust anchors, a certificate that does not
chain to them, a certificate that does not match a declared identity — and
validity is deliberately not among them; then, further down, it requires an
expired certificate to be reported with delivery continuing. RFC 5425 §5 opens
"If the peer does not meet the requirements of the security policy, the TLS
handshake MUST be aborted with an appropriate TLS alert", and RFC 5280 §6.1 makes
validity dates an input to path validation rather than a separate check. The
carve-out was never principled, and it is resolved below by deleting it.
#719's premise is wrong. It states that Mbed TLS "offers no direct counterpart
to SSL_CTX_check_private_key". mbedtls_pk_check_pair exists, takes &crt->pk
— a public field — and is cheap: the RSA path ignores the RNG and compares N and
E, and the EC path compares exported public keys. There is no sign-and-verify
round trip, so the code-space argument that ticket offers for "document as
deliberate" does not hold. #719 is rewritten accordingly.
The requirement that reshaped the plan
An earlier draft of this work was additive throughout. It does not survive one
requirement: credential material should be fetched when it is used, not held.
That matters on its own — a device holding a parsed private key in RAM for its
whole uptime is holding it for the attacker too — and it interacts with #753.
A fingerprint pins the whole DER certificate, so it changes on every peer
renewal. Adding rotating material as a static configuration field now and moving
it behind a provider later would cost two API breaks. One is the budget.
Decisions
| Question |
Decision |
| Fingerprint vs trust anchor |
Alternative. A CA bundle or a fingerprint set; at least one required. Fingerprint-only skips chain validation, per §4.2.1 |
| Hash algorithms |
sha-256 and sha-1. Meets the §4.2.2 MUST and keeps syslog-ng and rsyslog interoperability; the documentation recommends sha-256 |
| Certificate validity |
Enforced, with no tolerance setting. Validity is part of path validation, so a peer outside its dates is not authorised. What changes is the diagnosis, not the outcome |
| Core extraction |
Fingerprint parser, findings word and decision table in Core, under the guardrail that no TLS library type appears in any Core signature |
| Cipher policy (#733) |
Route 1 — expose the setting on both. OpenSSL gains a TLS 1.3 ciphersuite string; Mbed TLS a ciphersuite-ID array |
| Where credentials come from |
A credentials role per TLS pack, with a shipped default backend. Not a backend-neutral abstraction |
ServerName |
Stays on the stream configuration. Not secret, not bulky; moving it doubles the churn for no custody gain |
Why one Create taking either, rather than two stream classes
Corrected 2026-08-22. An earlier version of this paragraph argued that RFC
5425 §6.1 names trust-anchor validation combined with fingerprint matching as
its recommended default. Read against the text, it does not. §6.1 says the
threats are mitigated "only if both the transport sender and transport receiver
are properly authenticated and authorized, as described in Sections 5.1 and
5.2", and the paragraphs after it contrast that with the unauthenticated
policies of §5.3 to §5.5. It is about both endpoints being authenticated, by
one of the two methods - not about combining both methods. Requiring both where
an integrator supplied both is this library's choice, and docs/tls.md states
it as one.
The decision stands on its remaining grounds, which are the load-bearing ones.
The pool rule would force either two pools or shared-pool machinery that does
not exist. Nine of twelve configuration fields are identical between the two
modes. And the rotation contract, with #735, assumes authorisation material can
change while the stream lives, which two classes would turn into a
destroy-recreate-rewire.
Why an out-of-date certificate is refused, with no way to tolerate it
This reverses an earlier decision to offer two opt-in tolerance flags. Three
reasons, and the first is the one that settles it.
Revocation checking is already declined. The contract says so, deliberately,
because many industrial deployments have no route to a CRL or an OCSP responder.
That makes notAfter the only remaining mechanism by which a certificate ever
stops being trusted. A setting that tolerates expiry would remove the last
time-based control, so a collector key retired or compromised today would stay
acceptable indefinitely. Declining revocation and tolerating expiry are each
defensible; together they are not.
Validity is not a separate check to carve out. RFC 5280 §6.1 makes the
validity period an input to path validation, so a certificate outside it does not
chain to a trusted anchor. That is already one of the three cases the contract
says delivery stops for.
A tolerance is cheap to add and expensive to withdraw. Nothing about adding
one later is breaking, whereas removing one is. With no field integrations yet,
there is no demand to weigh against the argument above.
What is given up, honestly. A device with no real-time clock that boots at the
epoch sees a valid certificate as not yet valid, and cannot connect until it has
time. That device is the motivation for the contract's "delivery is preferred to
silence" principle, so this is a real cost rather than a free win. It is accepted
because such a device also stamps every record with an untrustworthy RFC 5424
TIMESTAMP, which is close to useless to a SIEM and misleading in an audit
trail — the engineering answer is a time source before logging, not accepting
certificates that cannot be validated. Store-and-forward covers the gap, and
blocked delivery is delayed delivery.
Where an operator controls the CA and genuinely cannot supply a time source, RFC
5280 §4.1.2.5's 99991231235959Z — whose worked example is an embedded device —
removes the expiry side of the problem at the certificate rather than in the
client.
So #731 changes shape rather than closing. Its premise, that the contract
requires report-and-continue, stops being true. But both adapters today refuse
the handshake with no useful diagnosis, and an integrator whose device will not
connect deserves to be told which check failed. #731 becomes exactly that.
Architecture: a credentials role per pack
Each TLS pack defines its own credentials role — a vtable with a Null object and
a shipped default backend — rather than one abstraction spanning the backends.
The material is irreducibly backend-typed: an mbedtls_x509_crt* is useless to
OpenSSL. A neutral role would have to traffic in bytes, which cannot express a
hardware-held private key at all, so it would buy uniformity in the type system
by giving up the case that motivates the work.
struct SolidSyslogMbedTlsCredentials
{
/** Install trust anchors and, for mTLS, the client credential. Called once
* per connection from Open, after the transport connects, so material is
* fetched only for a connection actually being made. */
bool (*Install)(struct SolidSyslogMbedTlsCredentials* self,
struct mbedtls_ssl_config* conf,
struct SolidSyslogTlsCredentialsInstalled* installed);
/** Release what Install acquired. Called from Close, exactly once per
* Install that returned true. */
void (*Release)(struct SolidSyslogMbedTlsCredentials* self);
};
OpenSSL is the same shape with SSL_CTX* in place of mbedtls_ssl_config*.
Shipped backends preserve today's model, so the default migration is mechanical:
SolidSyslogOpenSslPemFileCredentials takes the three PEM paths, and
SolidSyslogMbedTlsHandleCredentials takes the three caller-built handles. Both
are pool-allocated under a role-named tunable.
What the seam buys. A TPM, PKCS#11, keyring or PSA-opaque backend becomes a
new class inside the pack — additive, with no further break. wolfSSL (#691) gets
its own. Zephyr (#694), whose SOL_TLS route provisions sec_tag_t values
through the kernel credential store, becomes a credentials backend rather than an
awkward fit. And the use-after-free sequencing that
docs/platforms/mbedtls/index.md currently documents as an integrator obligation
disappears: the library now announces the window through Release.
The peer-authorisation decision itself stays in Core and is called, not
injected. The codebase injects what varies by deployment and calls what is the
library. Making the RFC 5425 policy swappable would let an integrator replace it,
which defeats the purpose.
The honest limit
While a connection is open the TLS library holds the parsed material, the private
key included, and nothing here changes that. What changes is the window: from the
stream's lifetime to Open-to-Close. On Mbed TLS the library holds pointers and
never copies, so whether the bytes actually leave RAM is what the backend's
Release does. This is written on both platform pages rather than implied.
Compatibility: one break, and it is loud
Six fields are removed — CaBundlePath, ClientCertChainPath, ClientKeyPath on
OpenSSL; CaChain, ClientCertChain, ClientKey on Mbed TLS. Everything else in
this epic is additive.
Every stale call site is a compile error. Nobody silently loses their trust
anchors, which is what makes a one-shot break tolerable.
CHANGELOG.md records that the public API changes before 1.0.0 only if
integration feedback or a security fix requires it. This is neither on a literal
reading, so the release note says plainly that it is a design correction taken
while no field integration exists, and that it is the only one planned before
1.0.0.
The one genuine loosening is #734. OpenSSL currently refuses to connect on a
half-supplied client credential; afterwards it connects without one and reports.
A deployment using that refusal as a misconfiguration tripwire loses it. It is the
only change moving in the less-safe direction and is called out on its own.
Error enum discipline. Both stream error enums are unnumbered, so a member
inserted mid-list silently renumbers every code after it for a handler compiled
against the old header. Append before _MAX only, never insert.
Delivery: an integration branch
All of this lands on feature/tls-rework. Each step is a normal pull request,
squash-merged into that branch; when the whole is right the branch goes to main
as a single merge commit, with squash-only lifted for that one merge so
release-please renders the break as its own changelog entry.
Two reasons. The API cannot be judged one pull request at a time — that is the
point of the exercise. And release-please runs on every push to main, so a break
landing mid-sequence would raise a release pull request proposing 0.2.0 with a
half-migrated TLS API in it.
Consequences worth stating so they do not read as oversights:
- The pull-request gates were scoped to
main alone and now name the branch as
well; all four revert when it merges.
- GitHub closes issues only on merge to the default branch, so the intermediate
pull requests carry no Closes keyword — they all go in the final
branch-to-main pull request. This epic and its issues therefore stay open,
and the board reads 0%, until the whole lands.
Sequence
Four independent threads. Only three orderings are real: #734 after #718, because
the reporting half is what makes continuing safe; #753 after #731, which
establishes the verify hook they share; and the pack work after the role header.
| Order |
Issue |
Note |
| 0 |
(no issue) |
The contract, the compliance rows and both platform pages. Zero code |
| 1 |
#718 |
Mbed TLS reports the credential-install failure |
| 2 |
#734 |
OpenSSL half-credential reports and continues |
| 3 |
#719 |
The key/certificate pairing check |
| 4 |
#732 |
Create validates the wiring. Wider than TLS: four classes across two packs |
| 5 |
#731 |
Name the failed check when a handshake is refused; installs the per-certificate verify hook |
| 6 |
#733 |
Cipher policy binds the negotiated connection |
| 7 |
#740 |
Assert a symbol named in the docs exists. Before the removals, so they fail CI rather than needing a manual audit |
| 8 |
— |
The credentials role headers and Null objects, both packs. Nothing removed yet |
| 9 |
— |
Each pack moves to the role. The break |
| 10 |
— |
An Mbed TLS PEM-buffer backend that parses in Install and zeroises in Release — so the release ships custody rather than only declaring it |
| 11 |
#753 |
Fingerprints, both adapters |
| 12 |
#735 |
A stream-version function on the sender, beside the endpoint's. Additive Core |
Open question to settle before step 9
The OpenSSL fingerprint-only path is unverified. Mbed TLS with a NULL
ca_chain was checked against the 3.6.2 sources: the verify callback is still
invoked at depth 0 with a writable flags word, so fingerprint-only works. The
OpenSSL equivalent — SSL_VERIFY_PEER with no anchors loaded and the callback
returning 1 at depth 0 to override X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT_LOCALLY
— is assumed rather than verified. It is the load-bearing assumption under "the CA
becomes optional", so it is proved against the real library in the integration
lane before the configuration shape is fixed.
What testing can and cannot show
Provable by unit test. The parser and the decision table are pure functions
over small closed input spaces, so every reachable combination is enumerated
rather than sampled. The same holds for every Create validation case, and for
Release firing exactly once on each of the five paths into Close.
Demonstrable by integration test against the real libraries: pinned
self-signed connect with no CA, mismatched pin refused, expired and not-yet-valid
certificates refused with the reason named, and material provably gone between
Close and the next Open.
Demonstrable end to end by BDD: fingerprint-pinned delivery to syslog-ng and
otelcol, mutual TLS unbroken after convergence, a half credential still delivering
to a non-mTLS collector.
Not provable here, and worth naming rather than implying otherwise: that this
reading of §5.1 satisfies an assessor; interoperability with rsyslog, Graylog or
Splunk, none of which the BDD suite runs; and TLS 1.3's late client-certificate
rejection under real network conditions.
Out of scope
Revocation checking stays outside the contract, for the reasons the contract
already gives — and the validity decision above now depends on that staying true.
Session resumption stays out of scope, as E26 decided. Neither is reopened here.
Overview
Eight of the issues on the 0.2.0 milestone concern TLS — #718, #719, #731, #732,
#733, #734, #735 and #753. They arrived separately, from the S23.22 documentation
audit and from writing the TLS contract, and were filed as independent defects.
Read together against the code and the RFC text they are not independent, and
three of them were blocked on decisions nobody had taken.
This epic takes those decisions once, sequences the work against them, and
carries the one deliberate API break that comes with them.
The outcome it is for: RFC 5425 §5.1 moves off Not Met, the two shipped TLS
adapters stop diverging from each other, where credentials come from becomes the
integrator's choice rather than ours, and
docs/tls.mdsays what the code does.Three findings that drove the design
docs/tls.mdcannot be satisfied as written. Its trust-anchor obligation — "aStreamthat cannot load them fails to open" — contradicts the fingerprintobligation on the same page, because RFC 5425 §4.2.1 says an end-entity
certificate matched by fingerprint "can be self-signed, and no certification path
validation is needed". One of the two has to give.
The page also argues with itself about certificate validity. It lists exactly
three things that stop delivery — no trust anchors, a certificate that does not
chain to them, a certificate that does not match a declared identity — and
validity is deliberately not among them; then, further down, it requires an
expired certificate to be reported with delivery continuing. RFC 5425 §5 opens
"If the peer does not meet the requirements of the security policy, the TLS
handshake MUST be aborted with an appropriate TLS alert", and RFC 5280 §6.1 makes
validity dates an input to path validation rather than a separate check. The
carve-out was never principled, and it is resolved below by deleting it.
#719's premise is wrong. It states that Mbed TLS "offers no direct counterpart
to
SSL_CTX_check_private_key".mbedtls_pk_check_pairexists, takes&crt->pk— a public field — and is cheap: the RSA path ignores the RNG and compares N and
E, and the EC path compares exported public keys. There is no sign-and-verify
round trip, so the code-space argument that ticket offers for "document as
deliberate" does not hold. #719 is rewritten accordingly.
The requirement that reshaped the plan
An earlier draft of this work was additive throughout. It does not survive one
requirement: credential material should be fetched when it is used, not held.
That matters on its own — a device holding a parsed private key in RAM for its
whole uptime is holding it for the attacker too — and it interacts with #753.
A fingerprint pins the whole DER certificate, so it changes on every peer
renewal. Adding rotating material as a static configuration field now and moving
it behind a provider later would cost two API breaks. One is the budget.
Decisions
sha-256andsha-1. Meets the §4.2.2 MUST and keeps syslog-ng and rsyslog interoperability; the documentation recommendssha-256ServerNameWhy one
Createtaking either, rather than two stream classesCorrected 2026-08-22. An earlier version of this paragraph argued that RFC
5425 §6.1 names trust-anchor validation combined with fingerprint matching as
its recommended default. Read against the text, it does not. §6.1 says the
threats are mitigated "only if both the transport sender and transport receiver
are properly authenticated and authorized, as described in Sections 5.1 and
5.2", and the paragraphs after it contrast that with the unauthenticated
policies of §5.3 to §5.5. It is about both endpoints being authenticated, by
one of the two methods - not about combining both methods. Requiring both where
an integrator supplied both is this library's choice, and
docs/tls.mdstatesit as one.
The decision stands on its remaining grounds, which are the load-bearing ones.
The pool rule would force either two pools or shared-pool machinery that does
not exist. Nine of twelve configuration fields are identical between the two
modes. And the rotation contract, with #735, assumes authorisation material can
change while the stream lives, which two classes would turn into a
destroy-recreate-rewire.
Why an out-of-date certificate is refused, with no way to tolerate it
This reverses an earlier decision to offer two opt-in tolerance flags. Three
reasons, and the first is the one that settles it.
Revocation checking is already declined. The contract says so, deliberately,
because many industrial deployments have no route to a CRL or an OCSP responder.
That makes
notAfterthe only remaining mechanism by which a certificate everstops being trusted. A setting that tolerates expiry would remove the last
time-based control, so a collector key retired or compromised today would stay
acceptable indefinitely. Declining revocation and tolerating expiry are each
defensible; together they are not.
Validity is not a separate check to carve out. RFC 5280 §6.1 makes the
validity period an input to path validation, so a certificate outside it does not
chain to a trusted anchor. That is already one of the three cases the contract
says delivery stops for.
A tolerance is cheap to add and expensive to withdraw. Nothing about adding
one later is breaking, whereas removing one is. With no field integrations yet,
there is no demand to weigh against the argument above.
What is given up, honestly. A device with no real-time clock that boots at the
epoch sees a valid certificate as not yet valid, and cannot connect until it has
time. That device is the motivation for the contract's "delivery is preferred to
silence" principle, so this is a real cost rather than a free win. It is accepted
because such a device also stamps every record with an untrustworthy RFC 5424
TIMESTAMP, which is close to useless to a SIEM and misleading in an audittrail — the engineering answer is a time source before logging, not accepting
certificates that cannot be validated. Store-and-forward covers the gap, and
blocked delivery is delayed delivery.
Where an operator controls the CA and genuinely cannot supply a time source, RFC
5280 §4.1.2.5's
99991231235959Z— whose worked example is an embedded device —removes the expiry side of the problem at the certificate rather than in the
client.
So #731 changes shape rather than closing. Its premise, that the contract
requires report-and-continue, stops being true. But both adapters today refuse
the handshake with no useful diagnosis, and an integrator whose device will not
connect deserves to be told which check failed. #731 becomes exactly that.
Architecture: a credentials role per pack
Each TLS pack defines its own credentials role — a vtable with a Null object and
a shipped default backend — rather than one abstraction spanning the backends.
The material is irreducibly backend-typed: an
mbedtls_x509_crt*is useless toOpenSSL. A neutral role would have to traffic in bytes, which cannot express a
hardware-held private key at all, so it would buy uniformity in the type system
by giving up the case that motivates the work.
OpenSSL is the same shape with
SSL_CTX*in place ofmbedtls_ssl_config*.Shipped backends preserve today's model, so the default migration is mechanical:
SolidSyslogOpenSslPemFileCredentialstakes the three PEM paths, andSolidSyslogMbedTlsHandleCredentialstakes the three caller-built handles. Bothare pool-allocated under a role-named tunable.
What the seam buys. A TPM, PKCS#11, keyring or PSA-opaque backend becomes a
new class inside the pack — additive, with no further break. wolfSSL (#691) gets
its own. Zephyr (#694), whose
SOL_TLSroute provisionssec_tag_tvaluesthrough the kernel credential store, becomes a credentials backend rather than an
awkward fit. And the use-after-free sequencing that
docs/platforms/mbedtls/index.mdcurrently documents as an integrator obligationdisappears: the library now announces the window through
Release.The peer-authorisation decision itself stays in Core and is called, not
injected. The codebase injects what varies by deployment and calls what is the
library. Making the RFC 5425 policy swappable would let an integrator replace it,
which defeats the purpose.
The honest limit
While a connection is open the TLS library holds the parsed material, the private
key included, and nothing here changes that. What changes is the window: from the
stream's lifetime to
Open-to-Close. On Mbed TLS the library holds pointers andnever copies, so whether the bytes actually leave RAM is what the backend's
Releasedoes. This is written on both platform pages rather than implied.Compatibility: one break, and it is loud
Six fields are removed —
CaBundlePath,ClientCertChainPath,ClientKeyPathonOpenSSL;
CaChain,ClientCertChain,ClientKeyon Mbed TLS. Everything else inthis epic is additive.
Every stale call site is a compile error. Nobody silently loses their trust
anchors, which is what makes a one-shot break tolerable.
CHANGELOG.mdrecords that the public API changes before 1.0.0 only ifintegration feedback or a security fix requires it. This is neither on a literal
reading, so the release note says plainly that it is a design correction taken
while no field integration exists, and that it is the only one planned before
1.0.0.
The one genuine loosening is #734. OpenSSL currently refuses to connect on a
half-supplied client credential; afterwards it connects without one and reports.
A deployment using that refusal as a misconfiguration tripwire loses it. It is the
only change moving in the less-safe direction and is called out on its own.
Error enum discipline. Both stream error enums are unnumbered, so a member
inserted mid-list silently renumbers every code after it for a handler compiled
against the old header. Append before
_MAXonly, never insert.Delivery: an integration branch
All of this lands on
feature/tls-rework. Each step is a normal pull request,squash-merged into that branch; when the whole is right the branch goes to
mainas a single merge commit, with squash-only lifted for that one merge so
release-please renders the break as its own changelog entry.
Two reasons. The API cannot be judged one pull request at a time — that is the
point of the exercise. And release-please runs on every push to
main, so a breaklanding mid-sequence would raise a release pull request proposing 0.2.0 with a
half-migrated TLS API in it.
Consequences worth stating so they do not read as oversights:
mainalone and now name the branch aswell; all four revert when it merges.
pull requests carry no
Closeskeyword — they all go in the finalbranch-to-
mainpull request. This epic and its issues therefore stay open,and the board reads 0%, until the whole lands.
Sequence
Four independent threads. Only three orderings are real: #734 after #718, because
the reporting half is what makes continuing safe; #753 after #731, which
establishes the verify hook they share; and the pack work after the role header.
Createvalidates the wiring. Wider than TLS: four classes across two packsInstalland zeroises inRelease— so the release ships custody rather than only declaring itOpen question to settle before step 9
The OpenSSL fingerprint-only path is unverified. Mbed TLS with a NULL
ca_chainwas checked against the 3.6.2 sources: the verify callback is stillinvoked at depth 0 with a writable flags word, so fingerprint-only works. The
OpenSSL equivalent —
SSL_VERIFY_PEERwith no anchors loaded and the callbackreturning 1 at depth 0 to override
X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT_LOCALLY— is assumed rather than verified. It is the load-bearing assumption under "the CA
becomes optional", so it is proved against the real library in the integration
lane before the configuration shape is fixed.
What testing can and cannot show
Provable by unit test. The parser and the decision table are pure functions
over small closed input spaces, so every reachable combination is enumerated
rather than sampled. The same holds for every
Createvalidation case, and forReleasefiring exactly once on each of the five paths intoClose.Demonstrable by integration test against the real libraries: pinned
self-signed connect with no CA, mismatched pin refused, expired and not-yet-valid
certificates refused with the reason named, and material provably gone between
Closeand the nextOpen.Demonstrable end to end by BDD: fingerprint-pinned delivery to syslog-ng and
otelcol, mutual TLS unbroken after convergence, a half credential still delivering
to a non-mTLS collector.
Not provable here, and worth naming rather than implying otherwise: that this
reading of §5.1 satisfies an assessor; interoperability with rsyslog, Graylog or
Splunk, none of which the BDD suite runs; and TLS 1.3's late client-certificate
rejection under real network conditions.
Out of scope
Revocation checking stays outside the contract, for the reasons the contract
already gives — and the validity decision above now depends on that staying true.
Session resumption stays out of scope, as E26 decided. Neither is reopened here.