Skip to content

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 kafka dependency 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:

  1. You choose the documented managed Kafka PEM recipe, or supply an image-form service whose listener configuration and advertised host port already agree.
  2. You supply its server-side material — keystore, truststore, certificate — through security.serverArtifacts, which vouchfx streams into that container at topology-build time.
  3. 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:

  1. the language schema accepts a security: block on that target — it is rejected outright on every dependency kind except kafka;
  2. 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 both tls and mtls, checked at validation time so a suite claiming anything else fails before a container starts; and
  3. the provider that resolves the step's target actually 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.
  4. 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, minio and s3 address 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.http captures inbound webhook deliveries.
  • trace-expect.otlp receives 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, under mtls, 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 under mtls additionally 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:

  1. 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.
  2. Wait for 1.1 for the deferred kinds. The widening is additive; nothing you author today breaks.
  3. 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.