MastertheMesh
Istio · ambient · sidecar migration · Vault · cert-manager · istio-csr · RSA · ECDSA · kind
Live · Runs on kind · 100% upstream OSS

Sidecars to Ambient When Your PKI Is RSA

TO
Tom O'Rourke
EMEA Field CTO · Solo.io

Sidecars ask the mesh CA for RSA certificates. ztunnel can only ask for ECDSA P-256, there is no RSA option. So when the CA is Vault behind cert-manager istio-csr and the signing role is locked to key_type=rsa, the sidecar estate runs happily for years, and the very first ambient enrolment is rejected on the spot: role requires keys of type rsa. This lab hits that wall on purpose in a scratch namespace, fixes it with one Vault role change, then migrates a namespace to ambient under continuous load while the namespace that stays on sidecars keeps its RSA certificates untouched, same serial numbers and all. Everything is upstream OSS: Istio 1.30.3, cert-manager, istio-csr, Vault, one kind cluster.

Istio 1.30.3 (upstream) Vault PKI cert-manager istio-csr RSA sidecars EC ztunnel key_type=any zero-downtime migration kind

The problem, in one line: the key type of a workload certificate is chosen by the client, not the CA. Sidecar istio-agents send RSA-2048 CSRs by default. ztunnel sends ECDSA P-256 CSRs, and that is the only key type it can generate; there is no RSA option anywhere in ztunnel. So a Vault PKI role that enforces key_type=rsa serves a sidecar mesh perfectly for years, and rejects ambient's very first certificate request with role requires keys of type rsa.

The fix is one change: key_type=any on the Vault role. Vault then signs whatever key type the CSR carries, so sidecars keep getting RSA and ztunnel gets EC, both chained to the same RSA root. Not key_type=ec: that would break the next RSA renewal for every sidecar still in the estate, and the last step of this lab proves it.

TL;DR

  1. Flip the Vault role to key_type=any before the first ambient enrolment. It is a no-op for every existing workload; the ordering is the only thing that can bite you.
  2. Upgrade istio-csr with caTrustedNodeAccounts (v0.12.0 is the ambient-support floor, this lab runs v0.16.0), flip istiod to profile=ambient, install CNI and ztunnel. Nothing restarts, cert serials do not change.
  3. Roll each sidecar namespace once so re-injected sidecars accept HBONE. Skip it and ambient callers get plaintext-reset by STRICT sidecar services, in one direction only.
  4. Migrate namespace by namespace: label flip, rolling restart, fortio watching. Namespaces you never migrate keep their RSA certificates indefinitely, same issuer, same trust chain.
  5. Never set the role to ec while any sidecar remains: that is a sidecar outage on a timer. any is the migration posture; the client chooses the key type, the role just has to permit the mix you actually run.

What you'll build

One kind cluster, three app namespaces, one CA. Istio starts in sidecar mode with its built-in CA disabled: every proxy fetches its certificate from istio-csr, which turns each CSR into a cert-manager CertificateRequest, signed by Vault at pki_int/sign/istio-ca.

NamespaceData planeRole in the lab
ledgersidecar, start to finishnever migrates — its RSA certs must survive everything unchanged
paymentssidecar → ambientmigrates mid-lab and comes back with EC certs
preflightambient from birththe scratch namespace that takes the rejection so nothing real has to

The PKI mirrors a long-standing RSA estate, all of it in Vault:

LayerKeyWhere
Root CA CN=Lab Root CA (10y)RSA-4096Vault mount pki
Intermediate CN=Lab Intermediate CA (5y)RSA-4096Vault mount pki_int
Signing role istio-cakey_type=rsa key_bits=2048 — the setting the whole lab turns onpki_int/roles/istio-ca
Workload leafs (1h TTL)RSA-2048 from sidecars, ECDSA P-256 from ztunnelissued via istio-csr → cert-manager → pki_int/sign/istio-ca

In ledger and payments, httpbin serves and a client pod curls the other namespace every two seconds, logging the HTTP code, so each client log is a continuous record of cross-namespace (and later cross-dataplane) health. A fortio pod in ledger provides the bounded zero-downtime scoreboards.

How to use this guide. The demo is ten numbered scripts under scripts/, run in order, and every script prints each command before executing it, so an audience watches the real kubectl, helm and vault commands go by rather than a wrapper name. The green STEP blocks below follow the same order and show the commands each script runs. Everything is also automated end to end with assertions in scripts/e2e.sh. You need docker, kind, kubectl, helm, istioctl, jq and openssl. No licence, no registry auth: every image and chart is upstream.

Stand up the RSA baseline

One script builds the world a bank-shaped platform team would recognise: kind, cert-manager, Vault (dev mode), the RSA PKI, the Vault Issuer, istio-csr, and Istio in sidecar mode. The two istiod values that matter are pilot.env.ENABLE_CA_SERVER: "false", which retires istiod as a CA completely, and global.caAddress: cert-manager-istio-csr.cert-manager.svc:443, which points every proxy at istio-csr instead. From that moment Vault is the only thing in the cluster that can sign a workload certificate, and the Vault role is the only place key policy lives.

STEP 1

kind + Vault RSA PKI + istio-csr + Istio sidecar mode

istio-csr's readiness gates on the Vault Issuer actually working, so if this comes up, the whole Vault → cert-manager → istio-csr chain is proven before Istio even installs.

ends with RSA baseline up: Istio 1.30.3 (upstream OSS), CA = Vault (RSA root/intermediate, role key_type=rsa).

bashrun — the script prints every command it executes
./scripts/01-setup.sh
bashthe Vault PKI it creates (scripts/vault-pki.sh, via kubectl exec — no local vault CLI)
vault secrets enable pki
vault write pki/root/generate/internal common_name="Lab Root CA" key_type=rsa key_bits=4096 ttl=87600h

vault secrets enable -path=pki_int pki
vault write pki_int/intermediate/generate/internal common_name="Lab Intermediate CA" key_type=rsa key_bits=4096
vault write pki/root/sign-intermediate csr=... format=pem_bundle ttl=43800h
vault write pki_int/intermediate/set-signed certificate=...

# THE setting this lab is about — RSA only, like an estate that standardised years ago
vault write pki_int/roles/istio-ca \
  allowed_uri_sans="spiffe://*" allow_any_name=true enforce_hostnames=false require_cn=false \
  server_flag=true client_flag=true \
  key_type=rsa key_bits=2048 \
  ttl=1h max_ttl=24h
bashthe istio-csr install (RSA istiod cert, CertificateRequests preserved as the audit trail)
helm upgrade -i cert-manager-istio-csr cert-manager-istio-csr \
  --repo https://charts.jetstack.io -n cert-manager --version v0.16.0 -f - <<EOF
app:
  certmanager:
    namespace: istio-system
    preserveCertificateRequests: true
    issuer: { name: istio-ca, kind: Issuer, group: cert-manager.io }
  tls:
    trustDomain: cluster.local
    rootCAFile: /var/run/secrets/istio-csr/ca.pem     # the Vault ROOT — the mesh trust anchor
    certificateDNSNames: [cert-manager-istio-csr.cert-manager.svc]
  server:
    clusterID: Kubernetes
volumeMounts: [{ name: root-ca, mountPath: /var/run/secrets/istio-csr }]
volumes: [{ name: root-ca, secret: { secretName: istio-root-ca } }]
EOF
bashthe istiod install (sidecar mode, CA duties handed to istio-csr)
helm upgrade -i istiod istiod --repo https://istio-release.storage.googleapis.com/charts \
  -n istio-system --version 1.30.3 -f - <<EOF
global:
  caAddress: cert-manager-istio-csr.cert-manager.svc:443
pilot:
  env:
    ENABLE_CA_SERVER: "false"       # istiod no longer signs anything — Vault is the only CA
meshConfig:
  accessLogFile: /dev/stdout
EOF
How cert-manager reaches Vault. No stored Vault token anywhere: the Issuer uses Kubernetes auth with serviceAccountRef, so cert-manager mints a short-lived ServiceAccount token with the audience vault://istio-system/istio-ca and Vault's auth role checks that exact audience. The policy attached to it allows one path only: pki_int/sign/istio-ca. See yaml/00-pki/vault-issuer.yaml.
STEP 2

Deploy ledger + payments, both on sidecars, STRICT mTLS mesh-wide

STRICT from the very start is what keeps the certificate story honest: there is no plaintext fallback to hide behind, so if issuance ever breaks, traffic breaks with it.

all deployments 2/2 (app + sidecar); both client logs streaming 200.

bashrun
./scripts/02-deploy-apps.sh
# which runs, visibly:
kubectl apply -f yaml/10-apps/    # namespaces (istio-injection=enabled), apps, PeerAuthentication STRICT

kubectl -n ledger logs deploy/client --tail=3     # ledger -> payments, every 2s
kubectl -n payments logs deploy/client --tail=3   # payments -> ledger
STEP 3

Read the live certificates: RSA, chained to Vault

Not from Kubernetes objects, from the serving state: Envoy's SDS for sidecars. Keep the serial number in your head (or a variable); it is about to become the proof that nothing gets re-issued behind your back.

Public Key Algorithm: rsaEncryption and issuer=CN=Lab Intermediate CA in both namespaces.

bashrun
./scripts/03-show-certs.sh      # the whole inventory table — or by hand:

istioctl proxy-config secret deploy/httpbin -n ledger -o json \
  | jq -r '.dynamicActiveSecrets[] | select(.name=="default")
           | .secret.tlsCertificate.certificateChain.inlineBytes' \
  | base64 -d | openssl x509 -noout -text | grep -E 'Public Key Algorithm|Issuer|Serial' -A1
bashthe scoreboard, before anything changes
kubectl -n ledger exec deploy/fortio -c fortio -- \
  fortio load -c 4 -qps 25 -t 10s http://httpbin.payments:8000/status/200 | grep "Code "
# Code 200 : 250 (100.0 %)

Ambient arrives, and nothing moves

Four changes land here, in order, and none of them touches a running application: istio-csr learns to trust ztunnel, istiod flips to the ambient profile, and the CNI agent and ztunnel install. The istio-csr change is the interesting one. A sidecar requests a certificate for its own pod identity, but ztunnel authenticates as itself and requests certificates for the workload identities it fronts on its node. That impersonation model has to be explicitly authorised, and it is why ambient needs istio-csr v0.12.0 or newer: app.server.caTrustedNodeAccounts=istio-system/ztunnel.

STEP 4

Enable ambient under load

Run a fortio load in one terminal and the enable script in another. istio-csr restarts, istiod restarts, CNI and ztunnel appear, and the scoreboard should not notice: existing proxies keep their cached certs and renewals simply queue for the moment istio-csr is back.

fortio Code 200 : … (100.0 %) across the whole change; the ledger cert serial afterwards is identical — nothing was re-issued, nothing restarted. ztunnel is running but has sent zero CSRs, because nothing is enrolled yet.

bashrun
kubectl -n ledger exec deploy/fortio -c fortio -- \
  fortio load -c 4 -qps 25 -t 150s http://httpbin.payments:8000/status/200 &

./scripts/04-enable-ambient.sh    # istio-csr caTrustedNodeAccounts + istiod profile=ambient + CNI + ztunnel
bashwhat 04-enable-ambient.sh runs
helm upgrade cert-manager-istio-csr cert-manager-istio-csr --repo https://charts.jetstack.io \
  -n cert-manager --version v0.16.0 --reuse-values \
  --set "app.server.caTrustedNodeAccounts=istio-system/ztunnel"

helm upgrade istiod istiod --repo https://istio-release.storage.googleapis.com/charts \
  -n istio-system --version 1.30.3 --reuse-values \
  --set profile=ambient --set istio_cni.enabled=true

helm upgrade -i istio-cni cni --repo https://istio-release.storage.googleapis.com/charts \
  -n istio-system --version 1.30.3 --set profile=ambient ...

helm upgrade -i ztunnel ztunnel --repo https://istio-release.storage.googleapis.com/charts \
  -n istio-system --version 1.30.3 \
  --set caAddress=cert-manager-istio-csr.cert-manager.svc:443 ...   # same CA path as everyone else
STEP 5

One rolling restart of the sidecar namespace, before anything migrates

A sidecar's configuration is fixed at injection time: when a pod is created, the injection webhook writes the istio-proxy container into it using whatever template istiod has at that moment. Now that istiod is on the ambient profile, new pods are injected with ISTIO_META_ENABLE_HBONE=true, the flag that marks the proxy as able to accept HBONE, ambient's mTLS tunnel. But the pods already running were injected before the profile change, so their proxies carry no such flag. istiod therefore advertises those workloads as unable to accept HBONE, and ztunnel's only option for reaching them is plaintext, which your STRICT policy correctly refuses. The symptom appears later and looks baffling: ambient callers get connection resets from sidecar services that sidecar callers reach fine. One rolling restart re-creates the pods, and the webhook re-injects them with the current template. Do it now, while nothing is ambient yet.

re-injected pods carry ISTIO_META_ENABLE_HBONE=true; their fresh certs are still RSA, proving the rsa-only role keeps serving sidecars after ambient arrives.

bashrun
./scripts/05-interop-roll.sh
# which runs, visibly:
kubectl -n ledger rollout restart deploy/httpbin deploy/client deploy/fortio
kubectl -n ledger rollout status  deploy/httpbin deploy/client deploy/fortio --timeout=180s

# then shows three proofs: istiod's PROTOCOL=HBONE advertisement for every
# ledger workload (istioctl ztunnel-config workloads), the HBONE flag on the
# re-injected pods, and the fresh cert still being RSA:
istioctl ztunnel-config workloads <ztunnel-pod>.istio-system | grep ledger
# ledger  httpbin-...   10.244.1.20  istio-csr-worker  None  HBONE

Break it somewhere safe

The Vault role still says key_type=rsa. Instead of discovering what that means by migrating payments, enrol a namespace that exists to take the hit. preflight is born straight into ambient (the namespace carries istio.io/dataplane-mode=ambient), so the moment its pod starts, ztunnel prefetches a certificate for it, and Vault says no. This is the dev-cluster rehearsal that saves the production estate.

STEP 6

Enrol preflight and watch Vault reject ztunnel

Because istio-csr keeps every CertificateRequest (preserveCertificateRequests: true), the rejection is a durable, readable object, not a log line you have to catch.

a failed CertificateRequest whose message ends role requires keys of type rsa; no cert for preflight in ztunnel; the app pod itself is Running (its readiness is not gated on the mesh) but unreachable over mTLS.

bashrun
./scripts/06-preflight-break.sh
# enrols the namespace, waits for the rejection, then shows the denied
# CertificateRequest (with Vault's exact error) and ztunnel's log
textwhat you'll see
$ kubectl -n istio-system get certificaterequests
NAME              APPROVED   DENIED   READY   ISSUER     REQUESTER                                         AGE
istio-csr-8k2wp   True                False   istio-ca   system:serviceaccount:cert-manager:cert-manager   41s

$ kubectl -n istio-system get certificaterequest istio-csr-8k2wp \
    -o jsonpath='{.status.conditions[?(@.type=="Ready")].message}'
Failed to sign certificate request: Error making API request.
URL: POST http://vault.vault.svc:8200/v1/pki_int/sign/istio-ca
Code: 400. Errors:
* role requires keys of type rsa

The fix: key_type=any, and why not ec

The Vault PKI role API accepts four values for key_type: rsa, ec, ed25519 and any, and documents that key_bits is "ignored … in signing operations when key_type=any". That is precisely the mesh's case: istio-csr and cert-manager only ever use the sign endpoints, where the client generates the key and Vault signs a CSR. (any cannot be used where Vault generates the key itself, the issue endpoints and CA generation, so if other consumers share this role for key-generation, give the mesh its own role. Separate roles per consumer is good Vault hygiene anyway.)

STEP 7

Flip the role to any

One Vault write. No restarts, no re-issuance, no traffic impact. ztunnel's retry loop picks it up within a minute or two.

preflight now holds an ECDSA P-256 cert (Public Key Algorithm: id-ecPublicKey) issued by the same Lab Intermediate CA; ledger's cert is still RSA with the same serial it had before the flip.

bashrun
./scripts/07-vault-allow-ec.sh
# which runs, visibly (full parameter set — a role write REPLACES the role):
vault write pki_int/roles/istio-ca \
  allowed_uri_sans="spiffe://*" allow_any_name=true enforce_hostnames=false require_cn=false \
  server_flag=true client_flag=true \
  key_type=any \
  ttl=1h max_ttl=24h

Migrate payments, under load

STEP 8

Label flip + rolling restart, with fortio watching

The standard ambient migration: swap the namespace labels, restart the workloads to shed their sidecars. ztunnel prefetches certificates for new pods as they start (the deployment carries a 10s minReadySeconds so traffic never shifts before the cert is in place).

fortio 100% across the migration; payments pods are 1/1 (no sidecar container); EC certs for payments in ztunnel; both client logs still streaming 200 — sidecar to ambient in one direction, ambient to sidecar in the other, RSA on one side of every connection and EC on the other.

bashrun
kubectl -n ledger exec deploy/fortio -c fortio -- \
  fortio load -c 4 -qps 25 -t 90s http://httpbin.payments:8000/status/200 &

./scripts/08-migrate-payments.sh
# which runs, visibly (with the fortio scoreboard managed for you):
kubectl label ns payments istio.io/dataplane-mode=ambient istio-injection- --overwrite
kubectl -n payments rollout restart deploy/httpbin deploy/client
kubectl -n payments rollout status  deploy/httpbin deploy/client --timeout=180s
textztunnel's view of ambient -> sidecar after the migration (JSON access log)
{"scope":"access","message":"connection complete",
 "src.workload":"client-74888b7c79-6g7mr","src.namespace":"payments",
 "src.identity":"spiffe://cluster.local/ns/payments/sa/client",
 "dst.addr":"10.244.1.29:15008","dst.hbone_addr":"10.244.1.29:8080",
 "dst.service":"httpbin.ledger.svc.cluster.local",
 "dst.identity":"spiffe://cluster.local/ns/ledger/sa/httpbin", ...}

# :15008 + both identities = HBONE mTLS into the sidecar. Before the STEP 5 roll this same
# flow went to :8080 in plaintext with no identities, and STRICT reset it.
STEP 9

The final inventory

Read from the live serving state on both data planes. This table is the whole lab in six rows.

RSA everywhere nothing moved, EC only where ambient took over, one issuer for all of it.

text./scripts/03-show-certs.sh
NAMESPACE   WORKLOAD   DATAPLANE KEY ALGORITHM    SERIAL                 ISSUER
ledger      httpbin    sidecar   rsaEncryption    24A8082CAD11F94F5660…  CN=Lab Intermediate CA
ledger      client     sidecar   rsaEncryption    7F9127E237F9A9FB7DFD…  CN=Lab Intermediate CA
ledger      fortio     sidecar   rsaEncryption    49B7384281E1831A6D05…  CN=Lab Intermediate CA
payments    httpbin    ambient   id-ecPublicKey   39F5F1CE2894412EF9D2…  CN=Lab Intermediate CA
payments    client     ambient   id-ecPublicKey   02E6DEC7F88A01244E0B…  CN=Lab Intermediate CA
preflight   httpbin    ambient   id-ecPublicKey   27E727F4D5F9FD289E73…  CN=Lab Intermediate CA

Locking the role to EC is a sidecar outage

Set the role to key_type=ec while sidecars still exist and you have started a sidecar outage. New and restarted pods are hit immediately: their RSA CSR bounces and the pod never becomes ready. Every existing sidecar follows within one certificate TTL: its renewal CSR bounces the same way, and the certificate it is serving with expires. With this lab's 1h leaf TTL, the whole estate is dark within the hour.

STEP 10

Cause the outage on one replica, then repair it

The script sets the role to ec, scales ledger's httpbin to two replicas, and shows the new pod failing to get a certificate. Then it puts the role back to any and shows the stuck pod recover on its own, because the agent never stops retrying.

a failed CertificateRequest with role requires keys of type ec; the new replica stays unready until the role goes back to any, then comes up with no other action.

bashrun
./scripts/09-sidecar-outage.sh
# role -> ec, scale to 2, show the rejection and the stuck pod,
# role -> any, watch it heal, scale back to 1

Sharing the update with the InfoSec team

In most estates this migration goes through a security change request. Here is what belongs in it: three things to assess, what does not change, and one transitional note.

1. The CA relaxes key-policy enforcement during the migration window. With key_type=rsa the CA refuses anything that is not RSA-2048; with key_type=any it signs whatever key type the CSR carries. The exposure is bounded because only istio-csr can reach this role (through the cert-manager Issuer's Vault policy), and the only CSR generators behind it are istio-agent (RSA-2048) and ztunnel (ECDSA P-256). Keep the role dedicated to the mesh, and once the last sidecar is gone flip it to key_type=ec key_bits=256: the CA becomes a strict control point again, tighter than today. any is the transition posture with a defined exit, not the destination.

2. A new algorithm enters scope: ECDSA P-256. Check it against the approved-algorithms standard, but it is a strength upgrade, not a downgrade: P-256 gives roughly 128-bit security versus roughly 112-bit for RSA-2048, and it is approved in FIPS 186-5 and NCSC guidance. An internal standard that says "RSA-2048 minimum" almost certainly predates EC rather than prohibiting it, and that is exactly what the change request should get confirmed in writing. The chain shape (EC leaf signed by an RSA-4096 intermediate) is standard and valid; the roots, and any HSM backing them, are untouched. If there is a FIPS-validated-module requirement, note that upstream ztunnel builds are not FIPS-validated, while the Solo distribution of Istio ships a FIPS variant.

3. The certificate requester model changes, and this is the biggest item. Today every workload's istio-agent requests a certificate for its own identity, authenticated by its own ServiceAccount token. With ambient, caTrustedNodeAccounts authorises the ztunnel ServiceAccount to request certificates for other workloads' identities (the ones resident on its node). That concentrates trust: compromise of a ztunnel pod means the ability to obtain certificates for any workload on that node. The counter-argument InfoSec will want to hear is that this is node-level blast radius they already carry, since a compromised node can read the SA tokens of every pod on it today, so the effective boundary has not moved. But it is a genuine trust-model change at the CA and should be reviewed and accepted explicitly, not discovered later.

What does not change: the trust anchors (same root, same intermediate, same trust bundle), certificate TTLs, SPIFFE identity naming, SAN policy, STRICT mTLS enforcement, and the Vault audit trail. Every signing operation still lands in Vault's audit log exactly as now, and with preserveCertificateRequests the cluster keeps a durable record of every issuance and rejection, which is arguably an audit improvement. Attribution at the Vault door (requests arriving via the cert-manager Issuer identity rather than per-workload) is already true today with istio-csr, so it is not a delta.

One transitional note: during coexistence the estate serves a mix of RSA and EC leaf certificates. Anything that inspects or pins key types, such as TLS middleboxes or compliance scanners with expected-algorithm rules, should have its expectations updated before migration starts, or it will generate noise that looks like a security event and is not.

Framed that way, the change request is: two config values (key_type=any, caTrustedNodeAccounts), one algorithm addition that raises the security level, one explicitly-accepted trust delegation that stays within existing node blast radius, and a defined stricter end-state. A reviewable, boundable change rather than an open-ended one.

Clean up

RESET

Delete the cluster

everything gone; nothing was installed outside kind.

bashrun
./scripts/99-teardown.sh      # kind delete cluster --name istio-csr

See also

Versions

Built and verified on:

OSS
Gateway APIv1.5.1
Istio (upstream)1.30.3
Kubernetes (kind)1.35
Vault2.0.3 (chart 0.34.0)
cert-managerv1.21.1
cert-manager istio-csrv0.16.0