Skip to content

tls: make transport security a configured capability rather than a hard dependency, consistently across every deployment method #294

Description

@allamiro

What is true today

Audited across crates/, helm/, docker-compose.yml, examples/ and docs/.

Plane TLS today Can it be turned off? Verify toggle Custom CA
Metadata Raft peer mandatory mTLS no no yes — ca is a required path
Metadata admin mandatory mTLS no no yes
Replication (leader↔follower) mandatory mTLS no no yes
Native client (produce/fetch) mandatory mTLS no no yes
Observability (/metrics, /healthz, /readyz) none — plaintext only n/a (cannot be enabled) n/a n/a
S3 / object store optional yesverify_tls partial system trust store
PostgreSQL required for remote yes, loopback only sslmode sslrootcert

Evidence:

  • TlsPaths { ca, cert, key } — all three are non-optional PathBuf (crates/vtop-node/src/config.rs).
  • meta.tls and data.replica_tls are non-optional fields.
  • data.native_tls is Option, but a leader/standalone rejects None at startup: .ok_or("leader/standalone requires native_tls")?. The Option exists only because a follower serves no clients — not to allow plaintext.
  • grep -rniE "insecure|skip_verify|danger_accept|no_verify|accept_invalid" crates/ returns nothing. There is no verification toggle anywhere in the cluster planes.
  • No production code path binds a cluster listener without TLS; every plaintext TcpListener::bind is in a test.

So: the four cluster planes are TLS-mandatory with no configuration surface, the observability plane is plaintext with no way to secure it, and only the object-store client has the knobs. That is the inverse of a coherent design, and it is scattered across 37 TLS construction sites.

One thing to decide before building, because it reverses a documented decision

mTLS-only is not an accident of implementation — it is a stated security property:

  • docs/SECURITY_MODEL.md: "Native client ↔ broker | TLS 1.3 with mandatory client certificates"
  • docs/SECURITY_MODEL.md: "The native broker transport in vtop-broker is TLS 1.3 mTLS only."

And the existing verify_tls flag deliberately refuses the "disable verification" case:

Honest scope: this flag does NOT disable certificate verification for https:// endpoints… A self-signed or private-CA endpoint needs its CA in the system trust store; skipping verification is deliberately unsupported.

That stance is defensible: on the cluster planes the certificate CN is the authorization identity — a metadata node id, a broker UUID, a client principal. Disabling verification there does not merely weaken confidentiality, it removes authentication and authorization, because there would be nothing left binding a connection to an identity. Fencing, lease ownership and admin authorization all rest on it.

So the request splits into two parts with very different risk:

  1. Plaintext mode (TLS off entirely) — coherent, and worth having for local development, CI, and single-node evaluation. Needs an explicit, loud, refuse-by-default opt-in.
  2. TLS on but verification off — on these planes this is not "TLS without checks", it is "authentication removed while looking encrypted". If it is wanted, it should be scoped narrowly (a named test-only mode that refuses to start unless the listener is loopback, or gated behind a build feature) rather than a general config flag, and SECURITY_MODEL.md has to be amended to say so.

My recommendation: build (1) properly, and for (2) support custom CA / private PKI as the answer to the real underlying need (self-signed certs in test environments), which the codebase already treats as the correct path. If a true no-verify mode is still wanted after that, do it as its own decision with the doc change attached.

Proposed design

One TransportSecurity type, resolved once from configuration and threaded through, replacing the scattered TlsPaths requirements:

mode: mtls | tls | plaintext        # default mtls; plaintext must be opt-in
ca:   <path>                        # custom/private CA, per plane
cert, key: <path>                   # required unless plaintext
client_auth: required | none        # `tls` mode = server auth only
verify: full | ca-only              # hostname verification; NOT a way to skip it

Non-negotiables that fall out of the audit:

  • Refuse, never warn, on a downgrade the operator did not ask for. This is the pattern validate_endpoint_scheme already sets, and it is the right one.
  • Plaintext must be visible at runtime: a startup warning naming the plane, and a metric or /readyz detail an operator can alert on. A cluster silently running plaintext is worse than one that refuses.
  • A plaintext plane must refuse to talk to a TLS peer and vice versa, with an error naming both sides. Half-configured is the state that produces unexplainable handshake failures.
  • Per plane, not global. Plaintext client access with mTLS replication is a legitimate shape; one global switch cannot express it.
  • Observability gains the same surface so /metrics can be TLS where required. Today it cannot be, which is a real gap in the opposite direction (security: metrics endpoint spawns an unbounded task per connection #78 notes it is unauthenticated by design, but "no option" is different from "off by default").
  • Environment-variable overrides for every field, matching the VTOP_S3_VERIFY_TLS precedent, so a container can be configured without rewriting a mounted file.

Deployment surfaces, all four

  • Bare metal — the config file is the source of truth; document a plaintext single-node quickstart, since there is currently no way to run the engine at all without minting certificates.
  • Docker / composedocker-compose.yml does not deploy vtop-node at all today, so the cluster planes are unexercised there. A plaintext compose profile is the cheapest way to make the engine runnable in the lab.
  • Helmtls.metaSecretName / tls.dataSecretName are required and the chart refuses to render without them (security: lab compose binds to all interfaces with default credentials #81, deliberately). Under a plaintext mode they must become conditional, and the refusal must still fire whenever mode is not plaintext.
  • Kubernetes — the per-pod certificate CN convention is load-bearing for identity; plaintext mode has to explicitly drop the SAN/CN requirements rather than leave them half-checked.

Acceptance

  • TransportSecurity resolved from config + env, one type, no per-plane ad-hoc requirements
  • plaintext runs a full cluster: metadata quorum, replication, produce/fetch, observability
  • Mixed plaintext/TLS between peers is refused with an error naming both sides
  • Custom CA honoured on every plane, verified against a private PKI in a test
  • Helm renders and deploys both modes; k8s-smoke.sh covers plaintext as a variant
  • Compose gains a runnable plaintext cluster profile
  • SECURITY_MODEL.md states the modes, the defaults, and what each gives up
  • Enabling or disabling TLS changes no other behaviour — the existing suites pass in both modes

Milestone v0.4.0 (hardening), since v0.3.0 is gated on the durability arc.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions