From 562de0136f2722ed52e87e72cd08dd5f772d515a Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Thu, 5 Feb 2026 17:29:57 -0800 Subject: [PATCH 1/4] feat: simple encryption of creds --- apps/workspace-engine/pkg/secrets/secrets.go | 96 +++++++++++++++++++ .../pkg/workspace/jobagents/argo/argoapp.go | 18 +++- .../pkg/workspace/store/job_agents.go | 33 ++++++- .../test/e2e/engine_job_agent_test.go | 44 +++++++++ 4 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 apps/workspace-engine/pkg/secrets/secrets.go diff --git a/apps/workspace-engine/pkg/secrets/secrets.go b/apps/workspace-engine/pkg/secrets/secrets.go new file mode 100644 index 000000000..803cd9791 --- /dev/null +++ b/apps/workspace-engine/pkg/secrets/secrets.go @@ -0,0 +1,96 @@ +package secrets + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "fmt" + "os" + + "github.com/charmbracelet/log" +) + +type Encryption interface { + Encrypt(plaintext string) (string, error) + Decrypt(ciphertext string) (string, error) +} + +type AES256Encryption struct { + gcm cipher.AEAD +} + +func NewEncryption() Encryption { + keyStr := os.Getenv("CTRLPLANE_AES_256_KEY") + if keyStr == "" { + log.Error("CTRLPLANE_AES_256_KEY is not set, using noop encryption") + return &NoopEncryption{} + } + + if len(keyStr) != 32 { + log.Error("CTRLPLANE_AES_256_KEY must be 32 bytes, using noop encryption") + return &NoopEncryption{} + } + + key := []byte(keyStr) + block, err := aes.NewCipher(key) + if err != nil { + log.Error("failed to create cipher: %w", err) + return &NoopEncryption{} + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + log.Error("failed to create GCM: %w", err) + return &NoopEncryption{} + } + + return &AES256Encryption{gcm: gcm} +} + +// Encrypt encrypts plaintext and returns base64-encoded ciphertext +func (e *AES256Encryption) Encrypt(plaintext string) (string, error) { + nonce := make([]byte, e.gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", fmt.Errorf("failed to generate nonce: %w", err) + } + + ciphertext := e.gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// Decrypt decrypts base64-encoded ciphertext and returns plaintext +func (e *AES256Encryption) Decrypt(ciphertext string) (string, error) { + data, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return "", fmt.Errorf("failed to decode base64: %w", err) + } + + nonceSize := e.gcm.NonceSize() + if len(data) < nonceSize { + return "", fmt.Errorf("ciphertext too short") + } + + nonce, encrypted := data[:nonceSize], data[nonceSize:] + plaintext, err := e.gcm.Open(nil, nonce, encrypted, nil) + if err != nil { + return "", fmt.Errorf("failed to decrypt: %w", err) + } + + return string(plaintext), nil +} + +type NoopEncryption struct { +} + +func NewNoopEncryption() Encryption { + return &NoopEncryption{} +} + +func (e *NoopEncryption) Encrypt(plaintext string) (string, error) { + return plaintext, nil +} + +func (e *NoopEncryption) Decrypt(ciphertext string) (string, error) { + return ciphertext, nil +} diff --git a/apps/workspace-engine/pkg/workspace/jobagents/argo/argoapp.go b/apps/workspace-engine/pkg/workspace/jobagents/argo/argoapp.go index 99ec654d6..26a290dd2 100644 --- a/apps/workspace-engine/pkg/workspace/jobagents/argo/argoapp.go +++ b/apps/workspace-engine/pkg/workspace/jobagents/argo/argoapp.go @@ -14,6 +14,7 @@ import ( "workspace-engine/pkg/messaging" "workspace-engine/pkg/messaging/confluent" "workspace-engine/pkg/oapi" + "workspace-engine/pkg/secrets" "workspace-engine/pkg/templatefuncs" "workspace-engine/pkg/workspace/jobagents/types" "workspace-engine/pkg/workspace/releasemanager/verification" @@ -34,10 +35,12 @@ var _ types.Dispatchable = &ArgoApplication{} type ArgoApplication struct { store *store.Store verifications *verification.Manager + secrets secrets.Encryption } func NewArgoApplication(store *store.Store, verifications *verification.Manager) *ArgoApplication { - return &ArgoApplication{store: store, verifications: verifications} + secrets := secrets.NewEncryption() + return &ArgoApplication{store: store, verifications: verifications, secrets: secrets} } func (a *ArgoApplication) Type() string { @@ -91,15 +94,26 @@ func (a *ArgoApplication) Dispatch(ctx context.Context, dispatchCtx types.Dispat return nil } +func (a *ArgoApplication) decryptOrPlaintext(value string) string { + decrypted, err := a.secrets.Decrypt(value) + if err != nil { + return value + } + return decrypted +} + func (a *ArgoApplication) parseJobAgentConfig(jobAgentConfig oapi.JobAgentConfig) (string, string, string, error) { serverAddr, ok := jobAgentConfig["serverUrl"].(string) if !ok { return "", "", "", fmt.Errorf("serverUrl is required") } - apiKey, ok := jobAgentConfig["apiKey"].(string) + apiKeyRaw, ok := jobAgentConfig["apiKey"].(string) if !ok { return "", "", "", fmt.Errorf("apiKey is required") } + + apiKey := a.decryptOrPlaintext(apiKeyRaw) + template, ok := jobAgentConfig["template"].(string) if !ok { return "", "", "", fmt.Errorf("template is required") diff --git a/apps/workspace-engine/pkg/workspace/store/job_agents.go b/apps/workspace-engine/pkg/workspace/store/job_agents.go index b77559d75..eb2dffd99 100644 --- a/apps/workspace-engine/pkg/workspace/store/job_agents.go +++ b/apps/workspace-engine/pkg/workspace/store/job_agents.go @@ -3,22 +3,47 @@ package store import ( "context" "workspace-engine/pkg/oapi" + "workspace-engine/pkg/secrets" "workspace-engine/pkg/workspace/store/repository" + + "github.com/charmbracelet/log" ) func NewJobAgents(store *Store) *JobAgents { + secrets := secrets.NewEncryption() return &JobAgents{ - repo: store.repo, - store: store, + repo: store.repo, + store: store, + secrets: secrets, } } type JobAgents struct { - repo *repository.InMemoryStore - store *Store + repo *repository.InMemoryStore + store *Store + secrets secrets.Encryption +} + +func (j *JobAgents) encryptCredentials(jobAgent *oapi.JobAgent) error { + jobAgentConfig := jobAgent.Config + for k, v := range jobAgentConfig { + if k == "apiKey" { + encrypted, err := j.secrets.Encrypt(v.(string)) + if err != nil { + return err + } + jobAgentConfig[k] = encrypted + } + } + return nil } func (j *JobAgents) Upsert(ctx context.Context, jobAgent *oapi.JobAgent) { + if err := j.encryptCredentials(jobAgent); err != nil { + log.Errorf("error encrypting credentials, skipping job agent upsert: %v", err) + return + } + j.repo.JobAgents.Set(jobAgent.Id, jobAgent) j.store.changeset.RecordUpsert(jobAgent) } diff --git a/apps/workspace-engine/test/e2e/engine_job_agent_test.go b/apps/workspace-engine/test/e2e/engine_job_agent_test.go index a32347b30..aac6613ad 100644 --- a/apps/workspace-engine/test/e2e/engine_job_agent_test.go +++ b/apps/workspace-engine/test/e2e/engine_job_agent_test.go @@ -671,3 +671,47 @@ func TestEngine_JobAgentNameUniqueness(t *testing.T) { t.Fatal("agents should have different IDs") } } + +func TestEngine_JobAgentApiKeyEncryption(t *testing.T) { + t.Setenv("CTRLPLANE_AES_256_KEY", "01234567890123456789012345678901") + + jobAgentID := "job-agent-encrypted" + originalApiKey := "super-secret-api-key" + + engine := integration.NewTestWorkspace(t) + ctx := context.Background() + + ja := c.NewJobAgent(engine.Workspace().ID) + ja.Id = jobAgentID + ja.Name = "Encrypted Agent" + ja.WorkspaceId = engine.Workspace().ID + ja.Config = map[string]any{ + "serverUrl": "https://argocd.example.com", + "apiKey": originalApiKey, + "template": "my-app-template", + } + + engine.PushEvent(ctx, handler.JobAgentCreate, ja) + + retrievedJa, exists := engine.Workspace().JobAgents().Get(jobAgentID) + if !exists { + t.Fatal("job agent not found") + } + + storedApiKey, ok := retrievedJa.Config["apiKey"].(string) + if !ok { + t.Fatal("apiKey not found in config") + } + + if storedApiKey == originalApiKey { + t.Fatal("apiKey should be encrypted, but it matches the original plaintext") + } + + if retrievedJa.Config["serverUrl"] != "https://argocd.example.com" { + t.Fatalf("serverUrl should not be encrypted: got %v", retrievedJa.Config["serverUrl"]) + } + + if retrievedJa.Config["template"] != "my-app-template" { + t.Fatalf("template should not be encrypted: got %v", retrievedJa.Config["template"]) + } +} From eef25a6c34ea4b83ce39667071ed1c4420a85673 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Thu, 5 Feb 2026 22:21:57 -0800 Subject: [PATCH 2/4] include prefix --- apps/workspace-engine/pkg/secrets/secrets.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/workspace-engine/pkg/secrets/secrets.go b/apps/workspace-engine/pkg/secrets/secrets.go index 803cd9791..4eb90364f 100644 --- a/apps/workspace-engine/pkg/secrets/secrets.go +++ b/apps/workspace-engine/pkg/secrets/secrets.go @@ -7,10 +7,13 @@ import ( "encoding/base64" "fmt" "os" + "strings" "github.com/charmbracelet/log" ) +const AES_256_PREFIX = "aes256:" + type Encryption interface { Encrypt(plaintext string) (string, error) Decrypt(ciphertext string) (string, error) @@ -48,7 +51,7 @@ func NewEncryption() Encryption { return &AES256Encryption{gcm: gcm} } -// Encrypt encrypts plaintext and returns base64-encoded ciphertext +// Encrypt encrypts plaintext and returns base64-encoded ciphertext with aes256: prefix func (e *AES256Encryption) Encrypt(plaintext string) (string, error) { nonce := make([]byte, e.gcm.NonceSize()) if _, err := rand.Read(nonce); err != nil { @@ -56,12 +59,17 @@ func (e *AES256Encryption) Encrypt(plaintext string) (string, error) { } ciphertext := e.gcm.Seal(nonce, nonce, []byte(plaintext), nil) - return base64.StdEncoding.EncodeToString(ciphertext), nil + return AES_256_PREFIX + base64.StdEncoding.EncodeToString(ciphertext), nil } -// Decrypt decrypts base64-encoded ciphertext and returns plaintext +// Decrypt decrypts base64-encoded ciphertext (with aes256: prefix) and returns plaintext func (e *AES256Encryption) Decrypt(ciphertext string) (string, error) { - data, err := base64.StdEncoding.DecodeString(ciphertext) + if !strings.HasPrefix(ciphertext, AES_256_PREFIX) { + return "", fmt.Errorf("invalid ciphertext: missing %s prefix", AES_256_PREFIX) + } + + encoded := strings.TrimPrefix(ciphertext, AES_256_PREFIX) + data, err := base64.StdEncoding.DecodeString(encoded) if err != nil { return "", fmt.Errorf("failed to decode base64: %w", err) } From 944673b20f1570c451d16be9b2d1f5c174e6437d Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Mon, 9 Feb 2026 19:48:52 -0800 Subject: [PATCH 3/4] move aes key to global config --- apps/workspace-engine/pkg/config/env.go | 2 ++ apps/workspace-engine/pkg/secrets/secrets.go | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/workspace-engine/pkg/config/env.go b/apps/workspace-engine/pkg/config/env.go index 717583760..955104e86 100644 --- a/apps/workspace-engine/pkg/config/env.go +++ b/apps/workspace-engine/pkg/config/env.go @@ -40,4 +40,6 @@ type Config struct { RegisterAddress string `envconfig:"REGISTER_ADDRESS" default:""` TraceTokenSecret string `envconfig:"TRACE_TOKEN_SECRET" default:"secret"` + + AES256Key string `envconfig:"AES_256_KEY" default:""` } diff --git a/apps/workspace-engine/pkg/secrets/secrets.go b/apps/workspace-engine/pkg/secrets/secrets.go index 4eb90364f..b02546aac 100644 --- a/apps/workspace-engine/pkg/secrets/secrets.go +++ b/apps/workspace-engine/pkg/secrets/secrets.go @@ -6,8 +6,8 @@ import ( "crypto/rand" "encoding/base64" "fmt" - "os" "strings" + "workspace-engine/pkg/config" "github.com/charmbracelet/log" ) @@ -24,14 +24,14 @@ type AES256Encryption struct { } func NewEncryption() Encryption { - keyStr := os.Getenv("CTRLPLANE_AES_256_KEY") + keyStr := config.Global.AES256Key if keyStr == "" { - log.Error("CTRLPLANE_AES_256_KEY is not set, using noop encryption") + log.Error("AES_256_KEY is not set, using noop encryption") return &NoopEncryption{} } if len(keyStr) != 32 { - log.Error("CTRLPLANE_AES_256_KEY must be 32 bytes, using noop encryption") + log.Error("AES_256_KEY must be 32 bytes, using noop encryption") return &NoopEncryption{} } From 5b9ecd6b80ffb36c648548c2d3038f91833db330 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Mon, 9 Feb 2026 20:10:24 -0800 Subject: [PATCH 4/4] rabbit comments --- apps/workspace-engine/pkg/secrets/secrets.go | 4 ++-- .../pkg/workspace/store/job_agents.go | 11 ++++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/workspace-engine/pkg/secrets/secrets.go b/apps/workspace-engine/pkg/secrets/secrets.go index b02546aac..6741130e5 100644 --- a/apps/workspace-engine/pkg/secrets/secrets.go +++ b/apps/workspace-engine/pkg/secrets/secrets.go @@ -38,13 +38,13 @@ func NewEncryption() Encryption { key := []byte(keyStr) block, err := aes.NewCipher(key) if err != nil { - log.Error("failed to create cipher: %w", err) + log.Error("failed to create cipher", "error", err) return &NoopEncryption{} } gcm, err := cipher.NewGCM(block) if err != nil { - log.Error("failed to create GCM: %w", err) + log.Error("failed to create GCM", "error", err) return &NoopEncryption{} } diff --git a/apps/workspace-engine/pkg/workspace/store/job_agents.go b/apps/workspace-engine/pkg/workspace/store/job_agents.go index eb2dffd99..605049b23 100644 --- a/apps/workspace-engine/pkg/workspace/store/job_agents.go +++ b/apps/workspace-engine/pkg/workspace/store/job_agents.go @@ -2,6 +2,8 @@ package store import ( "context" + "fmt" + "strings" "workspace-engine/pkg/oapi" "workspace-engine/pkg/secrets" "workspace-engine/pkg/workspace/store/repository" @@ -28,7 +30,14 @@ func (j *JobAgents) encryptCredentials(jobAgent *oapi.JobAgent) error { jobAgentConfig := jobAgent.Config for k, v := range jobAgentConfig { if k == "apiKey" { - encrypted, err := j.secrets.Encrypt(v.(string)) + plaintext, ok := v.(string) + if !ok { + return fmt.Errorf("apiKey is not a string: %v", v) + } + if strings.HasPrefix(plaintext, secrets.AES_256_PREFIX) { + continue + } + encrypted, err := j.secrets.Encrypt(plaintext) if err != nil { return err }