Skip to content

Added mTLS between ate system components#237

Open
Zoe Zhao (zoez7) wants to merge 10 commits into
agent-substrate:mainfrom
zoez7:mtls
Open

Added mTLS between ate system components#237
Zoe Zhao (zoez7) wants to merge 10 commits into
agent-substrate:mainfrom
zoez7:mtls

Conversation

@zoez7

@zoez7 Zoe Zhao (zoez7) commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Part of #170
Establishes mutual TLS between all ate system components, and updates the certificate plumbing it depends on.

Main changes:

  1. The atenet router now verifies ate apiserver' serving certificate, and presents its client cert to ate apiserver. Previously the connection used InsecureSkipVerify.
  2. AteApi server verifies atelet's serving cert.

Minor bug fixes:

  1. Prevent servicednssigner from signing a cert with no DNS SANs. Also updated valkey cluster's cert configuration, because it was relying on the cert with empty DNS.
  • Tests pass
  • Appropriate changes to documentation are included in the PR

@ahmedtd

Copy link
Copy Markdown
Collaborator

Can you talk a little bit about the background of splitting out separate CAs / signers for the workerpools and ate-system components?

If at all possible, I would prefer to restrict us to two signers:

  • One that issues certs with DNS SANs based on the K8s services that a pod is part of (though opt-in with an annotation or label is fine).
  • One that issues SPIFFE certs that encode the k8s service account identity of the pod (and potentially, the pod ID, node ID etc in an additional extension).

The reason is that we can be reasonably confident that signers like these will land in upstream K8s in the near-ish future. The certificates here aren't special or unique to substrate, and they aren't part of the value proposition of the project. So I think we should plan to rebase on native K8s capabilities as they become available. That's easiest if we don't get very custom with the signers.

@zoez7

Copy link
Copy Markdown
Collaborator Author

I think the main motivations for splitting out separate CAs / signers was, if I mount the "podidentity" CA bundle as trusted client CAs in ate-system services, workerpool pods (therefore actors) would also be able to connect to ate-system servers like the Valkey cluster and atelet server.

But after typing it out loud - it seems this needs to be solved anyway (probably in a later milestone) by authorization inside servers such as valkey, instead of using separate CAs. Does that make sense to you?

@ahmedtd

Copy link
Copy Markdown
Collaborator

I think the main motivations for splitting out separate CAs / signers was, if I mount the "podidentity" CA bundle as trusted client CAs in ate-system services, workerpool pods (therefore actors) would also be able to connect to ate-system servers like the Valkey cluster and atelet server.

But after typing it out loud - it seems this needs to be solved anyway (probably in a later milestone) by authorization inside servers such as valkey, instead of using separate CAs. Does that make sense to you?

Yeah --- we should always draw a bright line between authentication and authorization. Assuming that a given client is authorized just because they were able to get a certificate from a particular CA has caused a lot of security problems in the past.

Unfortunately, for the specific case of Valkey we might be limited by its capabilities, in which case it might require a separate signer. Let me check the docs.

@ahmedtd

Copy link
Copy Markdown
Collaborator

It looks like valkey can do it: https://valkey.io/topics/tls/#:~:text=Client%20certificate%20authentication

@zoez7
Zoe Zhao (zoez7) force-pushed the mtls branch 3 times, most recently from 683d085 to 2e34e49 Compare June 16, 2026 01:16
Comment thread cmd/atenet/internal/router/router.go Outdated
Comment thread cmd/atenet/internal/router/router.go Outdated
Comment thread cmd/atenet/internal/router/router.go Outdated
Comment thread cmd/atenet/internal/router/router.go Outdated
Comment thread cmd/atenet/internal/router/router.go Outdated
Comment thread cmd/atenet/internal/router/router.go Outdated
Comment thread cmd/atenet/internal/router/router.go Outdated

@bowei Bowei Du (bowei) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some nits

Comment thread cmd/atenet/internal/router/config.go Outdated

@bowei Bowei Du (bowei) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Router changes look ok.

@bowei

Copy link
Copy Markdown
Collaborator

needs rebase

@bowei

Copy link
Copy Markdown
Collaborator

Ping on this?

Rebased onto main and integrated with the ateapi-auth (mtls|jwt) mode that
landed on main in agent-substrate#248.

Router (client):
- ateapi-auth=mtls now performs real mutual TLS: the router presents its
  podidentity credential bundle (--ateapi-client-cert) as a client cert and
  verifies ateapi's serving cert against the servicedns trust bundle
  (--ateapi-ca-certs), instead of the previous insecure TLS dial. No insecure
  fallback: missing material is a hard error.
- ateapi-auth=jwt (from agent-substrate#248) is unchanged and still routes through
  ateapiauth.DialOptions; otel tracing/metrics are preserved.

ateapi (server):
- The ateapiauth server interceptor now injects an authenticated principal
  (internal/principal) into the request context in both modes: the mtls
  authenticator extracts the client's SPIFFE identity from its mTLS peer
  cert, and the jwt authenticator records the verified token subject
  (VerifyBearerToken now returns the principal ID). The principal carries a
  Kind (mtls|jwt) and is included in RPC request logs.

Supporting changes:
- Move credbundle to internal/credbundle and add ClientLoader for client certs.
- podcertcontroller signers, valkey, and install-ate.sh updated to provision and
  mount the podidentity/servicedns bundles; manifests mount them into the
  router and apiserver pods.
The --ateapi-client-cert path is provided via the manifest instead of
a code default, matching how the other cert-material flags are wired.
Drop --ateapi-ca-certs: it duplicated --ateapi-ca-file (both are the CA
bundle used to verify ateapi's serving cert, split by auth mode). The
router now dials ateapi via ateapiauth.DialOptions in both modes, like
atecontroller, instead of hand-rolling the mtls TLS config. This also
gains TLS 1.2 minimum and --ateapi-server-name support in mtls mode.

Path defaults move to the manifest (--ateapi-ca-file previously
defaulted to the SA ca.crt, which never matches ateapi's servicedns-
signed serving cert), and the router's servicedns-ca trust bundle now
mounts at /run/servicedns-ca, matching ate-controller and deduping the
jwt overlay's second mount of the same bundle.
Port the file-layout refactors from PR agent-substrate#237 that were lost when the
mtls branch was redone on a newer main:

- Move NewRouterCmd from cmd/atenet/internal into the router package
  as cmd.go; root.go now calls router.NewRouterCmd().
- Move the config struct to config.go, unexport it (routerConfig), and
  group the ateapi client-auth fields under a nested authConfig.
- Drop the self-signed Envoy cert fallback: --envoy-cert-path is now
  required for HTTPS serving; SetTlsConfig takes just (port, path) and
  xDS always references the cert by filename.
@zoez7 Zoe Zhao (zoez7) changed the title Envoy router verifies ate apiserver's serving cert Added mTLS between ate system components Jul 12, 2026
@zoez7

Zoe Zhao (zoez7) commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

Sorry for the delay! Rebased and force-pushed this branch, also added the mTLS between other ate system components as well.

GetClientCertificate: credbundle.ClientLoader(clientBundlePath),
// Skip the default verification because the peer is dialed by IP and its
// certificate has no DNS/IP SAN.
InsecureSkipVerify: true,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we include DNS or IP SANs in the certs so we don't need to skip this? https://spiffe.io/docs/latest/spiffe-specs/x509-svid/#2-spiffe-id says:

An X.509 SVID MAY contain any number of other SAN field types, including DNS SANs.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is that kubelet first waits for the pod certificate volume mount https://github.com/kubernetes/kubernetes/blob/v1.34.0/pkg/kubelet/kubelet.go#L2066 before creating a pod, if we try to add IPAddress in cmd/podcertcontroller/internal/podidentitysigner/podidentitysigner.go, it will be empty.

Comment thread cmd/ateapi/internal/controlapi/dialer.go Outdated
Comment thread cmd/atelet/main.go Outdated
Comment thread internal/ateapiauth/client_test.go Outdated
Comment thread cmd/podcertcontroller/internal/podidentitysigner/podidentitysigner.go Outdated
const CTBPrefix = "podidentity.podcert.ate.dev:identity:"

// oidPodUID represents the Pod UID within a certificate extension.
var oidPodUID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 6, 1, 3}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How did we determine the object identifier? Is this a standard set of numbers?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is a standard set up numbers I registerered in cl/949739997. The implementation now mirrors the future upstream pod identity signer https://github.com/kubernetes/kubernetes/pull/127251/changes#diff-985739ccf0a8431134e42720fd9a285e18e9fe49a5586062ff405d205e648e06. All changes related to the extension are in 0003f76.

ExtraExtensions: []pkix.Extension{
{
Id: oidPodUID,
Critical: false,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we always reject certs that don't have this extension, should we also mark it as critical so that any systems that don't recognize it also reject it, or is that going too far (e.g. would that break identity federation)?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading https://datatracker.ietf.org/doc/html/rfc5280#section-4.2 I kept it as non-critical in case the cert will be used to authenticate to other system that is not the ate-apiserver.

// bundle on each handshake so that in-place pod-certificate rotations are
// picked up.
func ClientLoader(path string) func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
// TODO: Introduce caching.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible to fix this in this PR?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll create an open issue for this, there are a couple places in internal/credbundle/credbundle.go that we need to do this for and I didn't want to make this PR even bigger.

Comment thread cmd/ateapi/main.go
workerpoolCACerts = pflag.String("workerpool-ca-certs", "", "The file that contains the CA for verifying workerpool client certificates.")

ateletClientCredBundle = pflag.String("atelet-client-cred-bundle", "/run/podidentity.podcert.ate.dev/credential-bundle.pem", "Credential bundle presented as the client certificate when dialing atelet.")
ateletCACerts = pflag.String("atelet-ca-certs", "/run/podidentity.podcert.ate.dev/trust-bundle.pem", "CA bundle used to verify atelet serving certificates.")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see worker pool certs above, does that also currently cover other system components like the router, controllers, etc that talk to ateapi?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. It does. Once we add authorization to the ate api, the workerpool pods should not be able to talk to the ateapi.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we name it something more generic, then, to indicate the CA covers more than just the worker pool? Maybe just --ate-trust-bundle?

Comment thread internal/ateapiauth/server.go Outdated
// to ctx once ateapi has an authorization layer.
return ctx, nil
pInfo := principal.PrincipalInfo{
ID: "anonymous",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we actually bother to support "anonymous" or should we just flat out reject anything that lacks credentials? We had a lot of issues with anonymous auth in K8s and have done our best to disable it wherever possible at this point. Personally I'd lean towards always requiring authn.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 620a215. Now we use a chained authenticator, if a client does not present either bear token or peer cert, we fail the authentication.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants