Security compatibility matrix¶
The question this page answers: for each technology vouchfx talks to, can a suite built from current source reach a secured instance of it?
This page describes the inline environment.services / environment.dependencies path.
The current environment.source path refuses declared security before acquisition; its
Compose support does not imply TLS/mTLS confirmation. Source capabilities are not a verified
1.0.0 release claim.
If your infrastructure is authenticated — a broker demanding mutual TLS, an API behind client certificates, a database that refuses plaintext — this is the page to read before you write your first suite. It is a reference table, not a tutorial. For the authoring syntax see §3.2.6b of the DSL specification; for where the boundary moves next see the roadmap.
For a suite you can actually run, see
examples/security-mtls.e2e.yaml.
It demonstrates both halves of the shape this page describes against one topology — a REST call
presenting a client certificate to a TLS-terminating API, and a Kafka publish and consume over a
mutually-authenticated broker, with both services chaining to one private certificate authority.
It also shows the part that is easy to miss: the certificates are generated by a script that runs
before vouchfx, because every path under a security: block is checked before the first
container starts.
The short answer¶
- Kafka targets — TLS and mutual TLS through an author-configured image-form service
or the default managed
kafkadependency using the file-backed PEM recipe. Managed advertisement, client bootstrap and confirmation use the same allocated host address. The recipe's measured host/image scope is stated in that guide; it does not establish SASL or external-driver security support. - The HTTP family (
http.rest,http.soap,metrics-assert.prometheus) — reachable over HTTPS, presenting a client certificate, against any declared service. - Everything else — RabbitMQ, NATS, MongoDB, Redis, PostgreSQL, SQL Server, MySQL, Elasticsearch, and the remaining families — not reachable over a secured connection in 1.0.
Server material and client expectations¶
This is the single most likely surprise for a new enterprise adopter, so it is stated before the tables rather than after them.
A security: block declares the client expectation. Image-form services must provide their
own secured listener configuration. The default managed Kafka path additionally configures
its selected listener for TLS or mutual TLS from author-supplied PEM server artefacts; it
does not generate certificates or reuse the client key as the broker key.
So "vouchfx supports mutual-TLS Kafka" means this in practice:
- You choose the documented managed Kafka PEM recipe, or supply an image-form service whose listener configuration and advertised host port already agree.
- You supply its server-side material — keystore, truststore, certificate — through
security.serverArtifacts, which vouchfx streams into that container at topology-build time. - vouchfx configures the client: the step's producer or consumer connects over TLS with the certificate you declared.
You supply server material; vouchfx confirms the declared connection. Managed Kafka owns its listener mechanics. Services retain their author-owned server configuration. A security declaration alone cannot establish that files are readable or that a broker will start.
One consequence worth knowing up front: a secured service's default health check becomes a TCP
probe rather than an HTTP one, because a container health check cannot present a client certificate
and an HTTPS probe against a mutual-TLS listener would hold a working topology unhealthy forever.
Declare an explicit healthCheck against a separate unsecured port if you want a stronger probe.
The client pair must be PEM, inside the suite directory — an encrypted key needs clientKeyPassword¶
clientCert and clientKey are read as a PEM certificate and its matching PEM private key. A key
encrypted at rest — the form a corporate PKI hands out by default — is usable as it stands: leave it
encrypted and declare its passphrase beside it, as a whole ${secret:<source>/<path>} reference and
nothing else, since a literal passphrase is refused.
clientCert: certs/client.pem
clientKey: certs/client-key.pem
clientKeyPassword: ${secret:env/CLIENT_KEY_PASS}
The reference is resolved at first use of the certificate material — inside the certificate load,
once the topology is up — never at compile time, so no passphrase is baked into the compiled script
and the reproducibility envelope hashes the reference rather than the value. The Kafka providers set their
client library's ssl.key.password from the same resolved value, so both halves of a suite open the
same encrypted key. Only the ENCRYPTED PRIVATE KEY (PKCS#8) form can be opened this way: an openssl
legacy key, marked Proc-Type: 4,ENCRYPTED, must first be converted with openssl pkcs8 -topk8. A
passphrase declared against a key that is not encrypted, one declared with no clientKey at all, and
a reference resolving to an empty value are each refused with a diagnostic rather than ignored.
clientKeyPassword belongs to profile: mtls. A TLS declaration with a passphrase but no
client identity is refused during pre-topology validation, including direct engine embedding
that bypasses the document schema; its value is neither resolved nor included in the diagnostic.
An embedder bypassing both schema and preflight also receives that diagnostic from Core Kafka
emission or the current source's public KafkaSecurityV2Helper.Source. Frozen
KafkaSecurityHelper.Source consumers retain the legacy TLS diagnostic behaviour; they must
explicitly adopt the versioned helper
to change it. Existing providers need no rebuild merely to compose with the new generation.
In the current source, engine-provided Secrets accessors expose a scenario-local
ResolvedSecrets ledger. Resolutions and explicit Record registrations also populate
a separate run-wide (watch: session-wide) reporting ledger, so probe and sibling values remain redacted from
diagnostics and event artefacts. The public SecretAccessor(catalog, ledger) constructor
still shares the caller-supplied ledger when an embedding host explicitly chooses it.
What remains unavailable is a PKCS#12/PFX bundle carrying the certificate and key together —
there is no field for one — and a passphrase for caCert, a trust anchor being a public certificate
with no private key to unlock.
Both files must also sit inside the directory tree containing the .e2e.yaml. Every path in the
block is written relative to that directory and must resolve inside it; an absolute path is rejected
by name even when it points at the file you meant, so material kept in a shared machine-wide location
has to be copied in beside the suite. A ${secret:…} reference is no way round that: in a
path-valued field — caCert, clientCert, clientKey, serverArtifacts[].source — it is refused,
naming the field, because the engine reads and copies the file such a field points at while the
secrets subsystem yields a value, which is not a file.
security.endpoint takes a literal endpoint name or port; serverArtifacts[].target takes
a literal absolute container-file path. Secret-reference syntax is unsupported in either field
and is diagnosed before endpoint resolution or container-path shape checks.
caCert is the asymmetric case, and it invites the wrong generalisation: the trust anchor is loaded
through a path constructor that auto-detects PEM and DER, so one field is format-tolerant and the
other is not.
Engine 1.0.2 source capability¶
The Core HTTP family and engine-owned secured-endpoint probe now construct an offline client
certificate context from a leaf-first clientCert PEM and its supplied public CA certificates.
The loader requires the key-matching leaf first; duplicate, malformed, non-CA tail, over-32-entry
and PEM text exceeding 4,194,304 UTF-16 code units after file loading are refused. This is a decoded-text limit, not a byte bound on the initial file read. Additional certificates are borrowed from the scenario accessor,
not installed into host stores or promoted to trust anchors. Single-leaf files and encrypted-key
rules remain valid.
Declared TLS/mTLS paths set DisableCertificateDownloads before the handshake and retain
NoCheck revocation, hostname, server-authentication EKU and declared-CA checks. No-security
HTTP targets retain platform defaults. Providers using frozen SecurityHelper.Source retain
the old single-leaf/platform-chain limitation. New providers using HttpTlsHelper.Source must
set MinEngineVersion: "1.0.2" and use SocketsHttpHandler.
Hosted runtime acceptance for engine 1.0.3 passed on Windows and Linux: release run 38792849566, source b864c40bea2e4e0fcef4084ace0ec59bb97b4365, retained 5 passed, 0 failed, 0 skipped on each platform. The Core http.rest TLS 1.2 wire evidence contains the supplied client intermediate, refuses omitted and mismatched intermediates, and records zero production automatic AIA fetches against a native control that fetched the issuer. The scope and exclusions below still apply.
The secured-endpoint probe disables TLS session resumption in both the identity-bearing connection and its anonymous control, so the second connection cannot reuse an authenticated session.
On Windows, the HTTP TLS helper and secured-endpoint probe retain the offline certificate context with the supplied intermediates, then select its leaf explicitly when the server requests a client identity. Other platforms attach the context directly. For an uncached Windows handshake, this keeps initial credential acquisition anonymous while preserving offline chain preparation and the probe's anonymous control. The hosted Windows acceptance above exercised this selection path; it remains a blocking transport check for subsequent shipping commits.
Hosted transport acceptance¶
The reusable TLS transport acceptance workflow checks the selected commit on fresh GitHub-hosted Windows and Linux runners. Normal CI selects the pull-request head; releases select the tagged source commit. Each platform must report exactly four transport facts plus the existing final certificate guard: 5 passed, 0 skipped. The CI aggregate fails if either platform fails or is skipped, and release artifact building and publication depend on the transport jobs. Manual dispatch remains available; its SHA input selects source bytes and does not attest review approval.
The HTTP AIA case uses the production material accessor and generated Core HTTP helper, a native HTTP download control, and live checks of an owned issuer endpoint. Both peers must send only the server leaf. Automatic issuer responses open before the first server TLS write; server-credential setup requests are retained separately. The same-phase native control must download successfully after that boundary, ruling out a cached setup refusal as evidence of suppression. The production client must reject the incomplete chain with zero automatic downloads during its validation window. Evidence records the actual client callback’s chain-error rejection and zero decrypted HTTP request bytes read by the server. Server-side native handshake completion is recorded independently: it can precede the client’s managed certificate-policy rejection and does not prove client acceptance. Client-chain cases compare received TLS 1.2 Certificate messages with the exact supplied intermediate, then require omitted and mismatched candidates to refuse authentication. Each arm uses fresh issuers and keys. TLS 1.2 is the fixture’s observability choice; production protocol defaults are unchanged.
This scope proves Core http.rest through the new shared helper. It does not independently prove SOAP, Prometheus, raw probes, TLS 1.3, unrelated network activity, older external providers, or global certificate/private-key cleanliness. The final guard checks Windows process-owned intermediate stores; unreadable stores remain unverified, and the guard performs no native store operation on Linux. Local compilation and inert controls do not establish native acceptance. Retain both platform TRXs and the four public evidence records per platform at the final shipping commit before publishing a release.
How the division was derived¶
The table below distinguishes accepted client configuration from working target orchestration. A secured inline connection needs all four of the following:
- the language schema accepts a
security:block on that target — it is rejected outright on every dependency kind exceptkafka; - the engine's security-profile registry has a wiring registered for that target kind — the wired
set is exactly
{kafka dependency, any declared service}, for bothtlsandmtls, checked at validation time so a suite claiming anything else fails before a container starts; and - the provider that resolves the step's
targetactually configures its emitted client from the resolved security configuration — measured across all twenty-five Core providers, that is the two Kafka providers and the three HTTP-family providers, and no others. - the topology reaches readiness and the target advertises an address reachable by that client. Managed Kafka uses the selected allocated endpoint for both advertisement and client staging.
Rule 3 is what makes the answer honest. Rules 1 and 2 alone would only tell you which suites validate; a suite can validate and still connect in plaintext if nothing on the provider side ever reads the declaration. Because both gates run at validation time, they decide only which suites are accepted at all — which is why widening them later is additive and safe, and why 1.0 deliberately rejects more than it might have.
Matrix by step family¶
Every Core provider, and what it can reach.
| Provider | Resolves target against |
Current inline support | Notes |
|---|---|---|---|
http.rest |
services | Yes — TLS and mutual TLS | image:-form service only |
http.soap |
services | Yes — TLS and mutual TLS | image:-form service only |
metrics-assert.prometheus |
services | Yes — TLS and mutual TLS | image:-form service only |
mq-publish.kafka |
kafka dependency, or a service |
Yes — TLS and mutual TLS | Default managed PEM recipe or author-configured image-form service; see the recipe's proof scope |
mq-expect.kafka |
kafka dependency, or a service |
Yes — TLS and mutual TLS | Default managed PEM recipe or author-configured image-form service; see the recipe's proof scope |
mq-publish.rabbitmq |
rabbitmq dependency |
No | Server-side TLS deferred to 1.1 |
mq-expect.rabbitmq |
rabbitmq dependency |
No | Server-side TLS deferred to 1.1 |
mq-publish.nats |
nats dependency |
No | Server-side TLS deferred to 1.1 |
mq-expect.nats |
nats dependency |
No | Server-side TLS deferred to 1.1 |
mq-publish.redis |
redis dependency |
No | Server-side TLS deferred to 1.1 |
mq-expect.redis |
redis dependency |
No | Server-side TLS deferred to 1.1 |
cache-assert.redis |
redis dependency |
No | Server-side TLS deferred to 1.1 |
mq-publish.azureservicebus |
azureservicebus dependency |
No | Reaches the engine-provisioned emulator only |
mq-expect.azureservicebus |
azureservicebus dependency |
No | Reaches the engine-provisioned emulator only |
db-assert.postgres |
postgres dependency |
No | Deferred to 1.1 |
db-assert.mysql |
mysql dependency |
No | Deferred to 1.1 |
db-assert.sqlserver |
sqlserver dependency |
No | Deferred to 1.1 |
db-assert.mongodb |
mongodb dependency |
No | Server-side TLS deferred to 1.1; mutual TLS further out |
db-assert.dynamodb |
dynamodb dependency |
No | Reaches the engine-provisioned local double only |
cache-assert.elasticsearch |
elasticsearch dependency |
No | Out of scope — see below |
storage-assert.s3 |
s3 or minio dependency |
No | Reaches the engine-provisioned RustFS or MinIO only |
mail-expect.smtp |
mailpit dependency |
No | Deferred; not in 1.1's named set |
webhook-listen.http |
— (engine-hosted listener) | No — plaintext inbound | See Inbound listeners, below |
trace-expect.otlp |
— (engine-hosted receiver) | No — plaintext inbound | See Inbound listeners, below |
script.csharp |
— (no infrastructure target) | Not applicable | Reconciles no target |
Matrix by dependency kind¶
The security: block is accepted on exactly one dependency kind, kafka. On every other
kind it is rejected outright — the block, not merely a particular profile value, and tls and
mtls are refused identically.
environment.dependencies kind |
Accepts security: |
Status |
|---|---|---|
kafka |
Yes | Default managed PEM TLS/mTLS recipe; selected allocated listener is advertised and confirmed |
postgres |
No | Deferred to 1.1 |
sqlserver |
No | Deferred to 1.1 |
mysql |
No | Deferred to 1.1 |
mongodb |
No | Deferred to 1.1 (server-side TLS) |
redis |
No | Deferred to 1.1 (server-side TLS) |
rabbitmq |
No | Deferred to 1.1 |
nats |
No | Deferred to 1.1 |
elasticsearch |
No | Out of scope |
mailpit |
No | Not in 1.1's named set |
azureservicebus |
No | Emulator only |
dynamodb |
No | Local double only |
minio |
No | Local double only |
s3 |
No | Local double only |
An environment.services entry accepts security: unconditionally — but only in its image: form.
A project:-form service cannot declare it: its endpoints come from its own launch profile, so the
engine has none of its own to give an https scheme, and the declaration is rejected at
topology-build time.
That refusal does not make a project:-form service plaintext by definition, and the
difference matters here more than anywhere else on this page. Such a service has three states, not
two. It may be addressed over plaintext, which is what the engine's fixed selection rule prefers
whenever the project offers a plaintext listener. It may be refused a security: block, as above.
And it may be addressed over TLS with no engine-configured trust — either because the author
named an https listener through the service's endpoint: field, or because the project offers only
an https listener and the fixed rule selected the one that exists. An advisory announces
that third state whenever a step addresses such a service, precisely because a reader who stopped
at the paragraph above would be contradicted by their own terminal. It is printed there, and it
also reaches the JSON Lines event stream as a transport-notice record — so the announcement
survives into an archive, not just a console. Its reach is --events and --events-stream
only: the JUnit and HTML renderers ignore an event type they do not recognise, and --watch is
terminal-only: combining it with --events (or --json), --events-stream, --junit or
--html is refused as a usage error (exit 2) before discovery or startup. Remove the output
options to watch, or remove --watch to write reports.
What is absent in that state is engine-configured trust, not verification, and conflating the
two in either direction is wrong. vouchfx contributes no trust anchor, pins no peer, presents no
client identity and asserts nothing about the transport: there is no security: block to declare
any of it from. What still happens is the platform's own verification — for a step that makes an
HTTP request, the certificate that listener presents is validated against the host's default trust
store, full chain, exactly as any other .NET HTTPS request is. A host that does not already trust
that certificate fails the handshake, which is classified an environment error, and an
environment error does not fail the run unless --fail-on-env-error is passed. So a suite in this
state can finish green having verified nothing about the service it addressed — which is why the
advisory above is on the event stream as well as the terminal: a run archiving --events or
--events-stream keeps the record that says which state it was in, while a --junit/--html-only
pipeline still archives a green report with nothing in it about the transport. Securing the system
under test, in the sense the rest of this page uses, still requires the image: form.
What "not reachable" actually means for the excluded kinds¶
This distinction matters, because "the client library cannot do it" and "vouchfx does not wire it yet" are very different problems for an adopter to plan around. For these kinds it is the second.
The .NET client libraries behind most of the excluded kinds carry TLS through the connection
string: RabbitMQ flips on an amqps:// scheme, NATS on tls://, Redis on ,ssl=true, MongoDB
on tls=true, and the relational clients on their own equivalent keywords. Enabling server-side
TLS for those kinds is mechanically a scheme or keyword per kind, with no provider code change at
all — which is exactly why it is a cheap 1.1 extension rather than a redesign.
What is missing is a channel for it in 1.0. The connection string a provider consumes is built
by the engine from the orchestrated resource; there is no DSL field through which an author supplies
or amends one. The variables block cannot carry it either — names beginning with the engine's
reserved conn:: prefix are rejected by the schema, precisely so an author-supplied value cannot
overwrite a staged connection string. So there is no supported way to hand vouchfx a
TLS-enabled connection string for a dependency in 1.0.
There is also no substitute declaration that buys transport security back. Re-declaring the
technology under environment.services does not work: of the twenty-five Core providers, seventeen
resolve target exclusively against declared dependencies and reject a target that is not a
declared dependency of their own kind, and those seventeen cover every excluded kind. Moving
the declaration trades a schema rejection for a step-validation rejection and still leaves you
without a working suite. Only the three HTTP-family providers resolve target through declared
services (and the two Kafka providers accept either), so the service form is a working path for an
HTTP system under test and for a customer-supplied broker — and for nothing else.
Kafka needed provider-side code where these kinds do not, for the same underlying reason: its client configuration derives no transport decision from the bootstrap string, so the decision has to be made in the emitted client configuration rather than carried in a URL.
The deferrals, stated plainly¶
Rather than leaving blanks in the tables:
- Server-side TLS for RabbitMQ, NATS, MongoDB, Redis and the relational stores (PostgreSQL, SQL Server, MySQL) is deferred to 1.1 by owner decision, taken on 3 August 2026. The design and acceptance criteria are written and unchanged; only the release is deferred. Because both gates that exclude these kinds run at validation time, widening is additive and breaks nothing that validates today.
- Mutual TLS for MongoDB and Redis goes further out than server-side TLS for them. Neither .NET client accepts client certificates through a connection string — both are API-only — so the cheap connection-string path that delivers server-side TLS does not reach client identity for these two.
- Secured Elasticsearch is out of scope, not deferred. The engine provisions Elasticsearch with its security subsystem disabled, and enabling it requires an initialisation/setup container that this design does not add.
mailpit,azureservicebus,dynamodb,minioands3address engine-provisioned test doubles — an SMTP capture server, the Service Bus emulator, DynamoDB Local, MinIO, RustFS — reached at fixed, engine-supplied local credentials. Pointing these providers at a real, secured, credentialed service is not supported in 1.0 and is not part of 1.1's named set.- Mutual TLS on the engine's own inbound listeners is out of scope; see below.
Inbound listeners are plaintext¶
Two providers do not connect out to infrastructure — the engine stands up an ephemeral listener and waits for the system under test to call in:
webhook-listen.httpcaptures inbound webhook deliveries.trace-expect.otlpreceives OTLP trace exports.
Both bind plaintext HTTP on an OS-assigned port. Your system under test must be able to reach the engine host over plain HTTP to deliver a callback or a span. A system under test that can only make mutually-authenticated outbound calls would need the listener to present a server certificate, which it does not do. Plan for this when the system under test is itself hardened: it is the one place in a secured topology where vouchfx is the server rather than the client.
What a green secured suite actually proves¶
After topology startup and before the first step, the engine connects to every declared secured endpoint with the same material a step will use, and reports one of two named confirmation levels. A declaration it cannot confirm forces a nonzero exit as an environment error without additional gating flags. See CI integration for the complete exit policy.
The current source records successful probes as typed security-confirmation events in JSONL, an HTML evidence table and JUnit suite properties. These records are available in Core 1.0.7. Inspect the declared profile, observed endpoint, client-identity flag and named level together. A replayed record is old build-time evidence, not a fresh probe; its timestamp records emission. Certificate paths, passwords and free-form probe detail are excluded. The event contract defines the exact fields and reporting scope.
Confirmation requires successful topology startup. A typed failure of the declared secured
resource's own readiness, endpoint discovery or server-material access that prevents confirmation
now produces a non-zero default exit. An unrelated dependency failure or ambiguous global startup
failure retains the ordinary --fail-on-env-error policy. A successful process exit alone is not
a confirmation record; check that the secured target reached one of the levels below.
A green secured suite proves nothing about a secured file the run never executed, and it no longer
pretends to. A .e2e.yaml that parsed and was then refused for its contents never becomes a
scenario, so its declaration is confirmed by nothing — not even by a sibling whose probe confirmed
the same target, since nothing established that the refused file's environment block is the one the
topology started from. Such a file reddens the run with no gating flag, on run and
run --parallel N alike. See CI integration for the full rule.
TransportConfirmed— the endpoint speaks TLS and, undermtls, the engine presented the declared client certificate. It does not prove the peer demanded an identity: in TLS 1.3 a server cannot reject a missing client certificate during the handshake, so a listener that merely requests one is indistinguishable from an enforcing one at this level.AuthenticatedRoundTrip— the engine completed an application-protocol round trip over the secured connection, and undermtlsadditionally showed that a second connection presenting no client certificate failed the same exchange. Both halves are needed for the claim "the peer required an identity" to be true. Today this level is reached for Kafka-speaking targets.
Neither level says anything about authorisation — whether the presented identity may publish to or consume from a given topic is the broker's own per-request decision, and surfaces as an ordinary step-level environment error.
Where mutual TLS being enforced is itself the thing under test and the target is not one the engine can round-trip, assert it at the system under test — for example with a step that expects the rejection an unauthenticated caller should receive.
If your infrastructure is not on the delivered list¶
Three honest options, in order of how often they apply:
- Terminate TLS in front of the system under test, not in front of the store. Most enterprise requirements are about the API surface and the event bus, both of which 1.0 covers. A suite that drives a secured REST API and a mutual-TLS Kafka broker, and then asserts against a plaintext database inside the private topology vouchfx created, is a realistic and defensible shape — the database is not exposed beyond the suite's own network.
- Wait for 1.1 for the deferred kinds. The widening is additive; nothing you author today breaks.
- Raise it. If a specific kind blocks an adoption, say so on the issue tracker — the ordering within 1.1 is not fixed, and the deferred work is designed rather than merely intended.
Test certificate-store hygiene¶
On Windows, the Runtime, Orchestration and CLI test hosts finish with a guard for certificates carrying their own process token in the intermediate-CA stores. The blocking Windows solution lane includes these Docker-free guards. Linux runs skip the Windows store operation; a Linux pass does not prove Windows store hygiene.
The added CLI and Orchestration guards use exclusive final xUnit collections. Ordinary CLI collections retain their parallel execution and xUnit default relative ordering; Orchestration retains its existing assembly serialization. The final scan adds store-inspection time after those collections finish, without serialising every CLI test. The source census rejects a test host that creates or imports certificate material without the registered final guard. It recognises constructors, canonical authority factories, aliases and collection imports in C# syntax, ignoring comments and string examples; it is not whole-program analysis of dynamically generated code.
For a source and metadata check that does not invoke certificate generation or a store scan, run:
dotnet test tests/Vouchfx.Engine.Orchestration.Tests -c Release --filter "FullyQualifiedName~CertificateGuardWiringTests|FullyQualifiedName~CertificateGuardSourceCensusTests"
dotnet test tests/Vouchfx.Cli.Tests -c Release --filter "FullyQualifiedName~CertificateGuardWiringTests"
The existing cleanup matches exact owned thumbprints or this process token. It does not remove older processes' residue or abandoned private-key files. Unreadable stores remain unverified, and a crash can prevent the final guard from running. No expired-certificate, subject-prefix or global-store cleanup is performed. Ownership-aware orphan reclamation requires separate work; this adoption does not establish zero residue across processes.