Skip to content

kubectl ate admin can rotate the actor JWT signing key - #2140

Open
Max Thompson (thompsonmax) wants to merge 3 commits into
agent-substrate:mainfrom
thompsonmax:actor-jwt-key-rotation
Open

Max Thompson (thompsonmax) wants to merge 3 commits into
agent-substrate:mainfrom
thompsonmax:actor-jwt-key-rotation

Conversation

@thompsonmax

@thompsonmax Max Thompson (thompsonmax) commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Part of #1756.

  • Adds kubectl ate admin add-jwt-key, activate-jwt-key, and remove-jwt-key, so rotating the actor JWT signing key no longer means editing the pool Secret by hand. A new key is published but signs nothing until activated, which gives relying parties time to fetch it.
  • Adds list-jwt-keys, which prints each key's ID and algorithm and marks the active one, so finding the key to remove doesn't mean decoding a Secret full of private keys.
  • ConcretePool gains AddAuthority, Activate, RemoveAuthority, and ActiveID. They refuse a duplicate or empty key ID, activating a key the pool doesn't hold, and removing the key that signs.
  • Each command writes the Secret back conditional on the resourceVersion it read and retries on a conflict, so a concurrent edit isn't lost.
  • RS256 keys are now 4096-bit RSA instead of 2048-bit, and a pool refuses an RSA key of any other size, both when adding one and when loading the pool Secret. RS256 stays only for relying parties that require it, such as Azure workload identity federation; ES256 is still the default.
  • The pool commands share one Kubernetes client helper, and make-jwt-pool's flag variables are renamed so the new commands can reuse them.

The commands don't wait for relying parties; the operator decides when to activate and remove. A pool that already holds a 2048-bit RSA key fails to load until that key is rotated out.

Testing: unit tests for the pool methods, and fake-clientset tests for a full rotation, a conflict retry, refused changes, and the listing, plus tests that 2048-bit RSA keys are refused. Not run against a cluster.

  • Tests pass
  • Appropriate changes to documentation are included in the PR

Comment thread internal/localjwtauthority/localjwtauthority.go Outdated
Comment thread internal/localjwtauthority/localjwtauthority.go Outdated
"encoding/base64"
"fmt"

jose "github.com/go-jose/go-jose/v4"

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.

Don't use go-jose, we've reimplemented everything else in-tree. Serialize the key using X509.MarshalPKIXPublicKey, then take the SHA256 of that.

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.

Ah, this already got merged in #1961 (This PR was stacked on that one).

I think it makes the most sense to bundle that change to move off go-jose in #2132, I'll make the change there if that sounds good to you.

Rotating the actor JWT signing key meant editing the pool Secret by
hand. ConcretePool gains AddAuthority, Activate, and RemoveAuthority,
and kubectl ate admin gains add-jwt-key, activate-jwt-key, and
remove-jwt-key on top of them, so a rotation is: add a key, which is
published but signs nothing; activate it once relying parties have
refetched the key set; remove the old key once its tokens expire.

The pool refuses a duplicate or empty key ID, activating a key it does
not hold, and removing the key that signs. Each command reads the
Secret, changes the pool, and writes it back conditional on the
resourceVersion it read, rerunning the change against the current pool
on a conflict so a concurrent edit is not lost.

The pool commands now share one helper to build the Kubernetes client,
and make-jwt-pool's algorithm and key ID flag variables are renamed so
the new commands can share them.
A rotation ends by removing the old key by ID, and nothing showed the
pool's key IDs short of decoding the Secret, which prints the private
keys. list-jwt-keys prints each key's ID and algorithm and marks the one
that signs.

ConcretePool.ActiveID names the signing key, falling back to the first
authority when none is designated as SignJWT does, and RemoveAuthority
now uses it too so the listing and the removal check agree.
RS256 does not fix an RSA key size, and the 2048-bit keys
GenerateAuthority made are too weak for the actor JWT signing key.
RS256 is kept only for relying parties that require it, such as Azure
workload identity federation; ES256 stays the default.

GenerateAuthority now makes 4096-bit RSA keys, and a pool refuses an
RSA key of any other size, both when one is added and when the pool
Secret is loaded. A pool holding a 2048-bit key fails to load until the
key is rotated out.
@wiz-alphabet

wiz-alphabet Bot commented Oct 6, 2026

Copy link
Copy Markdown

Wiz Scan Summary

Scanner Findings
Vulnerability Finding Vulnerabilities -
Data Finding Sensitive Data -
Secret Finding Secrets -
IaC Misconfiguration IaC Misconfigurations -
SAST Finding SAST Findings 1 Low
Software Management Finding Software Management Findings -
Total 1 Low

View scan details in Wiz

To detect these findings earlier in the dev lifecycle, try the Wiz Code extension for VS Code, JetBrains, or Visual Studio.

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.

2 participants