Skip to content

E39: TLS peer authorisation and adapter convergence #782

Description

@DavidCozens

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    epicEpic issue

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions