Your key never leaves
Your key never leaves
Three claims on this page. Each one is checkable against the chart you installed and the traffic you can capture.
- The signing key is generated inside your cluster and is never transmitted.
- The license agent can read and patch exactly one Secret, and read and update exactly one Lease. It has no other access to your cluster.
- All traffic is outbound. The Hub never opens a connection into your cluster.
The key is generated in your cluster
On first enrollment the license agent reads 32 bytes from the cluster's random source, derives an Ed25519 keypair from them, and stores the seed in the platform Secret in your namespace. That is the entire origin story of the key. We do not generate it, we do not escrow it, and there is no recovery path that lets us reconstruct it, because there is nothing for us to reconstruct it from.
What the Hub receives is the public half. Activation posts the public key. Every call after that carries a proof made with the private key, never the key itself.
If you want to confirm this from outside the code, capture the agent's egress. The activation request body contains a JWK with "kty":"OKP", "crv":"Ed25519", and an x parameter. x is the public key. There is no d parameter, which is where an Ed25519 private key would have to appear.
Proof of possession, not a bearer token
A license issued to you is bound to your key. The token carries a cnf (confirmation) claim, per RFC 7800, whose jkt member is the JWK thumbprint of your public key computed per RFC 7638.
Every renewal and every heartbeat is sent under DPoP, RFC 9449. The deployment presents its license as Authorization: DPoP <token> and attaches a DPoP header holding a short-lived proof JWT it signs with the private key. The proof binds the HTTP method, the canonical request URL, a fresh identifier, and a digest of the request body. The Hub verifies that proof against the jkt in the license's cnf before it does anything else.
The consequence is the point: a stolen license token, on its own, is inert. Whoever holds it cannot renew it, cannot check in with it, and cannot move it to another cluster, because they cannot produce a proof for it. Possession of the token is not possession of the license.
Rotation follows from the same mechanism. On each renewal the agent generates a fresh keypair, signs the renew request with the current key, and sends the new key's thumbprint in the signed body. The Hub rotates the cnf onto the new key only because the holder of the old one asked it to, in a request whose body is bound into the signed proof. If the Hub's response comes back bound to something other than the key the agent presented, the agent declines the rotation and keeps signing with the key the Hub still recognizes, rather than locking itself out.
What the agent can touch in your cluster
The license agent runs under its own ServiceAccount with a namespaced Role. Not a ClusterRole. This is the whole of it, straight from templates/license-agent-rbac.yaml, rendered here for a release named my-mvno:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: my-mvno-platform-license-agent
namespace: my-mvno
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["my-mvno-platform-env"]
verbs: ["get", "patch"]
- apiGroups: ["coordination.k8s.io"]
resources: ["leases"]
verbs: ["create"]
- apiGroups: ["coordination.k8s.io"]
resources: ["leases"]
resourceNames: ["my-mvno-platform-license-agent"]
verbs: ["get", "update"]
Read it rule by rule.
Secrets. get and patch, scoped by resourceNames to the single platform Secret. It cannot list Secrets, so it cannot enumerate what else is in your namespace. It cannot watch them. It cannot create them, and that omission is deliberate: create cannot be scoped by resourceNames, so granting it would widen this Role to every Secret in the namespace. The platform Secret already exists by the time the agent runs, so read-then-patch is all it ever needs.
Leases. The agent holds a coordination.k8s.io Lease and only runs its renew loop while it holds it, which is how the chart guarantees exactly one renewer even if something starts a second copy. get and update are scoped by name to that one Lease, which is named after the agent's own Deployment. create is the one verb that is not name-scoped, for the same reason it is absent above: resourceNames does not apply to create, and the Lease does not exist yet on a first install, so the agent has to be able to make it once. The blast radius of that verb is the ability to create a coordination Lease in its own namespace.
That is the complete list. No pods, no exec, no nodes, no ConfigMaps, no other namespaces, nothing cluster-scoped. You can verify it yourself:
kubectl -n my-mvno auth can-i --list \
--as=system:serviceaccount:my-mvno:my-mvno-platform-license-agent
Traffic goes one way
Your deployment calls the Hub. Three kinds of call, all HTTPS, all outbound, all from inside your namespace:
- Activation, once, at enrollment.
- Renewal, on a schedule.
- The heartbeat, on a schedule. What phones home lists every field it carries.
The Hub does not call you. There is no inbound webhook, no callback, no port you open for us, no credential we hold for your cluster, no ingress rule you add on our behalf. If the Hub wanted to reach into your cluster, it has nothing to reach with.
Revocation runs on the same one-way rail. We change a row on our side, and your deployment learns about it the next time it calls us. See Revoke and recover.
The Hub you enroll against is pinned
activation.parentUrl is the Hub your deployment talks to, and the activation code carries a fingerprint of that Hub's signing key. Before the agent redeems a code it fetches the key set from activation.parentUrl and checks the fingerprint matches. A mismatch is refused.
This holds on the recovery path too, not only on first install, which is the path an operator uses under pressure. Pointing a deployment at a different activation.parentUrl with a code minted by us does not enroll it there.
The chart still exposes activation.allowUnpinned, and in a released build that value does nothing. It is not a runtime toggle whose safety rests on nobody flipping it. The constant that gates the unpinned branch is compiled to false behind a !dev build tag, so in any binary we ship the branch is dead code and an unpinned code is refused whatever the value, the env var, or your values.yaml says. The escape hatch only exists in a binary built with the dev tag, for local work against a throwaway Hub, and that is not the binary in the image you pulled. Nothing an operator can set at install time reaches it.