Skip to content

Commit b0ed2b8

Browse files
markturanskyAmbient Code Botclaude
authored
feat(control-plane): replace TokenReview with RSA keypair auth for runner token endpoint (#1216)
## Summary - CP bootstraps an RSA-4096 keypair Secret (`ambient-cp-token-keypair`) in its namespace on startup via the project kube client; generates if missing - Private key loaded into token server for decryption; public key injected as `AMBIENT_CP_TOKEN_PUBLIC_KEY` into all runner Job pods - Runners RSA-OAEP/SHA-256 encrypt their `SESSION_ID` with the public key, send base64 ciphertext as `Authorization: Bearer` - CP decrypts to verify the caller — no `TokenReview` cluster permission required - Keypair persists across CP restarts in the K8s Secret; future path is Vault-backed ExternalSecret with no code change ## Motivation The CP SA does not have (and cannot be granted via tenant operator) cluster-scoped `create tokenreviews` permission. The previous `TokenReview`-based validation returned 401 for all runners. ## Test plan - [ ] Deploy CP — confirm `ambient-cp-token-keypair` Secret created in CP namespace on first boot - [ ] Create a session — confirm runner pod starts without `CP token endpoint unreachable` error - [ ] Confirm `acpctl session events $id` streams without 502 - [ ] Restart CP — confirm runner pods created after restart can still fetch tokens 🤖 Generated with [Claude Code](https://claude.ai/code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Automatic control-plane token keypair bootstrap on startup. * Added token server health check endpoint. * **Refactor** * Token authentication now uses RSA-encrypted session IDs with local cryptographic validation. * Runner now encrypts session IDs with the control-plane public key; public key is injected into runtime containers. * NetworkPolicy added to restrict access to the token endpoint. * **Chores** * Added cryptography dependency. * **Tests** * New unit tests for keypair bootstrapping, token handling, and runner token fetch behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Ambient Code Bot <bot@ambient-code.local> Co-authored-by: Claude <noreply@anthropic.com>
1 parent f5f7516 commit b0ed2b8

11 files changed

Lines changed: 787 additions & 89 deletions

File tree

components/ambient-control-plane/cmd/ambient-control-plane/main.go

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/ambient-code/platform/components/ambient-control-plane/internal/auth"
1616
"github.com/ambient-code/platform/components/ambient-control-plane/internal/config"
1717
"github.com/ambient-code/platform/components/ambient-control-plane/internal/informer"
18+
"github.com/ambient-code/platform/components/ambient-control-plane/internal/keypair"
1819
"github.com/ambient-code/platform/components/ambient-control-plane/internal/kubeclient"
1920
"github.com/ambient-code/platform/components/ambient-control-plane/internal/reconciler"
2021
"github.com/ambient-code/platform/components/ambient-control-plane/internal/tokenserver"
@@ -25,8 +26,6 @@ import (
2526
"google.golang.org/grpc"
2627
"google.golang.org/grpc/credentials"
2728
"google.golang.org/grpc/credentials/insecure"
28-
"k8s.io/client-go/rest"
29-
"k8s.io/client-go/tools/clientcmd"
3029
)
3130

3231
var (
@@ -123,6 +122,12 @@ func runKubeMode(ctx context.Context, cfg *config.ControlPlaneConfig) error {
123122
provisioner := buildNamespaceProvisioner(cfg, provisionerKube)
124123
tokenProvider := buildTokenProvider(cfg, log.Logger)
125124

125+
kp, err := keypair.EnsureKeypairSecret(ctx, provisionerKube, cfg.CPRuntimeNamespace, log.Logger)
126+
if err != nil {
127+
return fmt.Errorf("bootstrapping CP token keypair: %w", err)
128+
}
129+
log.Info().Str("namespace", cfg.CPRuntimeNamespace).Msg("CP token keypair ready")
130+
126131
factory := reconciler.NewSDKClientFactory(cfg.APIServerURL, tokenProvider, log.Logger)
127132
kubeReconcilerCfg := reconciler.KubeReconcilerConfig{
128133
RunnerImage: cfg.RunnerImage,
@@ -142,6 +147,7 @@ func runKubeMode(ctx context.Context, cfg *config.ControlPlaneConfig) error {
142147
RunnerLogLevel: cfg.RunnerLogLevel,
143148
CPRuntimeNamespace: cfg.CPRuntimeNamespace,
144149
CPTokenURL: cfg.CPTokenURL,
150+
CPTokenPublicKey: string(kp.PublicKeyPEM),
145151
}
146152

147153
conn, err := grpc.NewClient(cfg.GRPCServerAddr, grpc.WithTransportCredentials(grpcCredentials(cfg.GRPCUseTLS)))
@@ -184,7 +190,7 @@ func runKubeMode(ctx context.Context, cfg *config.ControlPlaneConfig) error {
184190

185191
tsErrCh := make(chan error, 1)
186192
go func() {
187-
tsErrCh <- startTokenServer(ctx, cfg, tokenProvider)
193+
tsErrCh <- startTokenServer(ctx, cfg, tokenProvider, kp)
188194
}()
189195

190196
infErrCh := make(chan error, 1)
@@ -203,24 +209,18 @@ func runKubeMode(ctx context.Context, cfg *config.ControlPlaneConfig) error {
203209
}
204210
}
205211

206-
func startTokenServer(ctx context.Context, cfg *config.ControlPlaneConfig, tokenProvider auth.TokenProvider) error {
207-
k8sConfig, err := buildK8sRestConfig(cfg.Kubeconfig)
212+
func startTokenServer(ctx context.Context, cfg *config.ControlPlaneConfig, tokenProvider auth.TokenProvider, kp *keypair.KeyPair) error {
213+
privKey, err := keypair.ParsePrivateKey(kp.PrivateKeyPEM)
208214
if err != nil {
209-
return fmt.Errorf("building k8s rest config for token server: %w", err)
215+
return fmt.Errorf("parsing CP token private key: %w", err)
210216
}
211-
ts, err := tokenserver.New(cfg.CPTokenListenAddr, tokenProvider, k8sConfig, log.Logger)
217+
ts, err := tokenserver.New(cfg.CPTokenListenAddr, tokenProvider, privKey, log.Logger)
212218
if err != nil {
213219
return fmt.Errorf("creating token server: %w", err)
214220
}
215221
return ts.Start(ctx)
216222
}
217223

218-
func buildK8sRestConfig(kubeconfig string) (*rest.Config, error) {
219-
if kubeconfig != "" {
220-
return clientcmd.BuildConfigFromFlags("", kubeconfig)
221-
}
222-
return rest.InClusterConfig()
223-
}
224224

225225
func createSessionReconcilers(reconcilerTypes []string, factory *reconciler.SDKClientFactory, kube *kubeclient.KubeClient, projectKube *kubeclient.KubeClient, provisioner kubeclient.NamespaceProvisioner, cfg reconciler.KubeReconcilerConfig, logger zerolog.Logger) []reconciler.Reconciler {
226226
var reconcilers []reconciler.Reconciler
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package keypair
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
"crypto/rsa"
7+
"crypto/x509"
8+
"encoding/base64"
9+
"encoding/pem"
10+
"fmt"
11+
12+
"github.com/ambient-code/platform/components/ambient-control-plane/internal/kubeclient"
13+
"github.com/rs/zerolog"
14+
k8serrors "k8s.io/apimachinery/pkg/api/errors"
15+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
16+
)
17+
18+
const (
19+
SecretName = "ambient-cp-token-keypair"
20+
privateKeyKey = "private.pem"
21+
publicKeyKey = "public.pem"
22+
rsaKeyBits = 4096
23+
)
24+
25+
type KeyPair struct {
26+
PrivateKeyPEM []byte
27+
PublicKeyPEM []byte
28+
}
29+
30+
func EnsureKeypairSecret(ctx context.Context, kube *kubeclient.KubeClient, namespace string, logger zerolog.Logger) (*KeyPair, error) {
31+
existing, err := kube.GetSecret(ctx, namespace, SecretName)
32+
if err == nil {
33+
return keypairFromSecret(existing)
34+
}
35+
if !k8serrors.IsNotFound(err) {
36+
return nil, fmt.Errorf("checking for keypair secret: %w", err)
37+
}
38+
39+
logger.Info().Str("namespace", namespace).Str("secret", SecretName).Msg("keypair secret not found, generating new RSA keypair")
40+
41+
kp, err := generateKeypair()
42+
if err != nil {
43+
return nil, fmt.Errorf("generating RSA keypair: %w", err)
44+
}
45+
46+
secret := &unstructured.Unstructured{
47+
Object: map[string]interface{}{
48+
"apiVersion": "v1",
49+
"kind": "Secret",
50+
"metadata": map[string]interface{}{
51+
"name": SecretName,
52+
"namespace": namespace,
53+
"labels": map[string]interface{}{
54+
"app": "ambient-control-plane",
55+
"ambient-code.io/managed-by": "ambient-control-plane",
56+
},
57+
},
58+
"type": "Opaque",
59+
"data": map[string]interface{}{
60+
privateKeyKey: base64.StdEncoding.EncodeToString(kp.PrivateKeyPEM),
61+
publicKeyKey: base64.StdEncoding.EncodeToString(kp.PublicKeyPEM),
62+
},
63+
},
64+
}
65+
66+
if _, createErr := kube.CreateSecret(ctx, secret); createErr != nil {
67+
if !k8serrors.IsAlreadyExists(createErr) {
68+
return nil, fmt.Errorf("creating keypair secret: %w", createErr)
69+
}
70+
existing, err = kube.GetSecret(ctx, namespace, SecretName)
71+
if err != nil {
72+
return nil, fmt.Errorf("re-reading keypair secret after race: %w", err)
73+
}
74+
return keypairFromSecret(existing)
75+
}
76+
77+
logger.Info().Str("namespace", namespace).Str("secret", SecretName).Msg("RSA keypair secret created")
78+
return kp, nil
79+
}
80+
81+
func keypairFromSecret(secret *unstructured.Unstructured) (*KeyPair, error) {
82+
data, _, _ := unstructured.NestedMap(secret.Object, "data")
83+
84+
privB64, ok := data[privateKeyKey].(string)
85+
if !ok || privB64 == "" {
86+
return nil, fmt.Errorf("keypair secret missing %q key", privateKeyKey)
87+
}
88+
pubB64, ok := data[publicKeyKey].(string)
89+
if !ok || pubB64 == "" {
90+
return nil, fmt.Errorf("keypair secret missing %q key", publicKeyKey)
91+
}
92+
93+
privPEM, err := base64.StdEncoding.DecodeString(privB64)
94+
if err != nil {
95+
return nil, fmt.Errorf("decoding private key from secret: %w", err)
96+
}
97+
pubPEM, err := base64.StdEncoding.DecodeString(pubB64)
98+
if err != nil {
99+
return nil, fmt.Errorf("decoding public key from secret: %w", err)
100+
}
101+
102+
return &KeyPair{PrivateKeyPEM: privPEM, PublicKeyPEM: pubPEM}, nil
103+
}
104+
105+
func generateKeypair() (*KeyPair, error) {
106+
privKey, err := rsa.GenerateKey(rand.Reader, rsaKeyBits)
107+
if err != nil {
108+
return nil, fmt.Errorf("generating RSA key: %w", err)
109+
}
110+
111+
privPEM := pem.EncodeToMemory(&pem.Block{
112+
Type: "RSA PRIVATE KEY",
113+
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
114+
})
115+
116+
pubDER, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey)
117+
if err != nil {
118+
return nil, fmt.Errorf("marshaling public key: %w", err)
119+
}
120+
pubPEM := pem.EncodeToMemory(&pem.Block{
121+
Type: "PUBLIC KEY",
122+
Bytes: pubDER,
123+
})
124+
125+
return &KeyPair{PrivateKeyPEM: privPEM, PublicKeyPEM: pubPEM}, nil
126+
}
127+
128+
func ParsePrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) {
129+
block, _ := pem.Decode(pemBytes)
130+
if block == nil {
131+
return nil, fmt.Errorf("failed to decode PEM block for private key")
132+
}
133+
return x509.ParsePKCS1PrivateKey(block.Bytes)
134+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package keypair
2+
3+
import (
4+
"context"
5+
"crypto/rsa"
6+
"encoding/base64"
7+
"testing"
8+
9+
"github.com/rs/zerolog"
10+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
11+
"k8s.io/apimachinery/pkg/runtime"
12+
"k8s.io/client-go/dynamic/fake"
13+
14+
"github.com/ambient-code/platform/components/ambient-control-plane/internal/kubeclient"
15+
)
16+
17+
func newFakeKubeClient(objects ...runtime.Object) *kubeclient.KubeClient {
18+
scheme := runtime.NewScheme()
19+
dynClient := fake.NewSimpleDynamicClient(scheme, objects...)
20+
return kubeclient.NewFromDynamic(dynClient, zerolog.Nop())
21+
}
22+
23+
func TestGenerateKeypair(t *testing.T) {
24+
kp, err := generateKeypair()
25+
if err != nil {
26+
t.Fatalf("generateKeypair() error: %v", err)
27+
}
28+
if len(kp.PrivateKeyPEM) == 0 {
29+
t.Error("PrivateKeyPEM is empty")
30+
}
31+
if len(kp.PublicKeyPEM) == 0 {
32+
t.Error("PublicKeyPEM is empty")
33+
}
34+
}
35+
36+
func TestParsePrivateKey(t *testing.T) {
37+
kp, err := generateKeypair()
38+
if err != nil {
39+
t.Fatalf("generateKeypair() error: %v", err)
40+
}
41+
privKey, err := ParsePrivateKey(kp.PrivateKeyPEM)
42+
if err != nil {
43+
t.Fatalf("ParsePrivateKey() error: %v", err)
44+
}
45+
if privKey == nil {
46+
t.Fatal("ParsePrivateKey() returned nil")
47+
}
48+
if _, ok := interface{}(privKey).(*rsa.PrivateKey); !ok {
49+
t.Error("parsed key is not *rsa.PrivateKey")
50+
}
51+
}
52+
53+
func TestParsePrivateKey_InvalidPEM(t *testing.T) {
54+
_, err := ParsePrivateKey([]byte("not a pem block"))
55+
if err == nil {
56+
t.Error("expected error for invalid PEM, got nil")
57+
}
58+
}
59+
60+
func TestKeypairFromSecret_MissingPrivateKey(t *testing.T) {
61+
secret := &unstructured.Unstructured{
62+
Object: map[string]interface{}{
63+
"apiVersion": "v1",
64+
"kind": "Secret",
65+
"metadata": map[string]interface{}{"name": SecretName, "namespace": "test"},
66+
"data": map[string]interface{}{
67+
publicKeyKey: base64.StdEncoding.EncodeToString([]byte("pub")),
68+
},
69+
},
70+
}
71+
_, err := keypairFromSecret(secret)
72+
if err == nil {
73+
t.Error("expected error for missing private key, got nil")
74+
}
75+
}
76+
77+
func TestKeypairFromSecret_MissingPublicKey(t *testing.T) {
78+
secret := &unstructured.Unstructured{
79+
Object: map[string]interface{}{
80+
"apiVersion": "v1",
81+
"kind": "Secret",
82+
"metadata": map[string]interface{}{"name": SecretName, "namespace": "test"},
83+
"data": map[string]interface{}{
84+
privateKeyKey: base64.StdEncoding.EncodeToString([]byte("priv")),
85+
},
86+
},
87+
}
88+
_, err := keypairFromSecret(secret)
89+
if err == nil {
90+
t.Error("expected error for missing public key, got nil")
91+
}
92+
}
93+
94+
func TestKeypairFromSecret_ValidSecret(t *testing.T) {
95+
kp, err := generateKeypair()
96+
if err != nil {
97+
t.Fatalf("generateKeypair() error: %v", err)
98+
}
99+
secret := &unstructured.Unstructured{
100+
Object: map[string]interface{}{
101+
"apiVersion": "v1",
102+
"kind": "Secret",
103+
"metadata": map[string]interface{}{"name": SecretName, "namespace": "test"},
104+
"data": map[string]interface{}{
105+
privateKeyKey: base64.StdEncoding.EncodeToString(kp.PrivateKeyPEM),
106+
publicKeyKey: base64.StdEncoding.EncodeToString(kp.PublicKeyPEM),
107+
},
108+
},
109+
}
110+
got, err := keypairFromSecret(secret)
111+
if err != nil {
112+
t.Fatalf("keypairFromSecret() error: %v", err)
113+
}
114+
if string(got.PrivateKeyPEM) != string(kp.PrivateKeyPEM) {
115+
t.Error("PrivateKeyPEM mismatch")
116+
}
117+
if string(got.PublicKeyPEM) != string(kp.PublicKeyPEM) {
118+
t.Error("PublicKeyPEM mismatch")
119+
}
120+
}
121+
122+
func TestEnsureKeypairSecret_CreatesWhenMissing(t *testing.T) {
123+
kube := newFakeKubeClient()
124+
ctx := context.Background()
125+
126+
kp, err := EnsureKeypairSecret(ctx, kube, "test-ns", zerolog.Nop())
127+
if err != nil {
128+
t.Fatalf("EnsureKeypairSecret() error: %v", err)
129+
}
130+
if len(kp.PrivateKeyPEM) == 0 || len(kp.PublicKeyPEM) == 0 {
131+
t.Error("returned keypair has empty PEM fields")
132+
}
133+
134+
privKey, err := ParsePrivateKey(kp.PrivateKeyPEM)
135+
if err != nil {
136+
t.Fatalf("generated private key is not parseable: %v", err)
137+
}
138+
if privKey.N.BitLen() != rsaKeyBits {
139+
t.Errorf("key size: got %d, want %d", privKey.N.BitLen(), rsaKeyBits)
140+
}
141+
}
142+
143+
func TestEnsureKeypairSecret_ReturnsExistingWhenPresent(t *testing.T) {
144+
ctx := context.Background()
145+
kube := newFakeKubeClient()
146+
147+
first, err := EnsureKeypairSecret(ctx, kube, "test-ns", zerolog.Nop())
148+
if err != nil {
149+
t.Fatalf("first call error: %v", err)
150+
}
151+
152+
second, err := EnsureKeypairSecret(ctx, kube, "test-ns", zerolog.Nop())
153+
if err != nil {
154+
t.Fatalf("second call error: %v", err)
155+
}
156+
157+
if string(first.PrivateKeyPEM) != string(second.PrivateKeyPEM) {
158+
t.Error("second call returned different private key — should reuse existing Secret")
159+
}
160+
}

components/ambient-control-plane/internal/reconciler/kube_reconciler.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ type KubeReconcilerConfig struct {
3939
RunnerLogLevel string
4040
CPRuntimeNamespace string
4141
CPTokenURL string
42+
CPTokenPublicKey string
4243
}
4344

4445
type SimpleKubeReconciler struct {
@@ -582,6 +583,7 @@ func (r *SimpleKubeReconciler) buildEnv(ctx context.Context, session types.Sessi
582583
envVar("USE_VERTEX", useVertex),
583584
envVar("CLAUDE_CODE_USE_VERTEX", useVertex),
584585
envVar("AMBIENT_CP_TOKEN_URL", r.cfg.CPTokenURL),
586+
envVar("AMBIENT_CP_TOKEN_PUBLIC_KEY", r.cfg.CPTokenPublicKey),
585587
envVar("AMBIENT_GRPC_URL", r.cfg.RunnerGRPCURL),
586588
envVar("AMBIENT_GRPC_ENABLED", boolToStr(r.cfg.RunnerGRPCURL != "")),
587589
envVar("AMBIENT_GRPC_USE_TLS", boolToStr(r.cfg.RunnerGRPCUseTLS)),
@@ -831,6 +833,7 @@ func (r *SimpleKubeReconciler) buildMCPSidecar() interface{} {
831833
envVar("MCP_BIND_ADDR", fmt.Sprintf(":%d", mcpSidecarPort)),
832834
envVar("AMBIENT_API_URL", r.cfg.MCPAPIServerURL),
833835
envVar("AMBIENT_CP_TOKEN_URL", r.cfg.CPTokenURL),
836+
envVar("AMBIENT_CP_TOKEN_PUBLIC_KEY", r.cfg.CPTokenPublicKey),
834837
},
835838
"resources": map[string]interface{}{
836839
"requests": map[string]interface{}{

0 commit comments

Comments
 (0)