Skip to content

feat: simple encryption of creds#792

Open
adityachoudhari26 wants to merge 2 commits intomainfrom
basic-encryption
Open

feat: simple encryption of creds#792
adityachoudhari26 wants to merge 2 commits intomainfrom
basic-encryption

Conversation

@adityachoudhari26
Copy link
Member

@adityachoudhari26 adityachoudhari26 commented Feb 6, 2026

Summary by CodeRabbit

  • New Features

    • Job agent API keys are now encrypted at rest using AES-256; encryption is configurable via an environment variable and falls back to plaintext if not configured or on error (with graceful handling).
    • API keys are stored encrypted while other configuration fields remain readable.
  • Tests

    • Added an end-to-end test verifying API key encryption, storage, and retrieval.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 6, 2026

📝 Walkthrough

Walkthrough

Adds a pluggable secrets package with AES‑256 GCM and noop implementations, encrypts JobAgent apiKey values before storage, decrypts apiKeys on use (with plaintext fallback), and adds an e2e test validating apiKey encryption.

Changes

Cohort / File(s) Summary
Encryption Framework
apps/workspace-engine/pkg/secrets/secrets.go
New package exposing Encryption interface, AES256Encryption (AES‑256 GCM, base64, per‑operation nonce) and NoopEncryption; NewEncryption() reads CTRLPLANE_AES_256_KEY and falls back to noop on error.
Job Agent Credential Encryption
apps/workspace-engine/pkg/workspace/store/job_agents.go, apps/workspace-engine/pkg/workspace/jobagents/argo/argoapp.go
Wires encryption into JobAgents and Argo application: JobAgents encrypts apiKey before upsert via encryptCredentials; Argo uses decryptOrPlaintext to obtain usable apiKey (falls back to original on error). Constructors updated to initialize encryption.
End-to-End Test
apps/workspace-engine/test/e2e/engine_job_agent_test.go
Adds TestEngine_JobAgentApiKeyEncryption asserting stored apiKey is encrypted (different from plaintext) while other config fields remain plaintext and encrypted values are retrievable as strings.

Sequence Diagram

sequenceDiagram
    participant Client as Client
    participant Store as JobAgents Store
    participant Enc as Encryption
    participant DB as Database
    participant Argo as ArgoApplication

    Client->>Store: Upsert JobAgent (config includes plaintext apiKey)
    Store->>Enc: Encrypt(apiKey)
    Enc-->>Store: encrypted(apiKey) [base64]
    Store->>DB: Store JobAgent (encrypted apiKey)
    DB-->>Store: OK

    Client->>Argo: Parse JobAgent config / request action
    Argo->>Enc: Decrypt(encrypted apiKey) or return plaintext
    Enc-->>Argo: plaintext apiKey (or error -> fallback)
    Argo-->>Client: Use apiKey for agent operations
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

Suggested reviewers

  • jsbroks

Poem

🐰 I hop where nonces glitter in the light,
Keys tuck in layers, hidden out of sight,
AES hums softly through the night,
If keys are lost, I guard with gentle might,
A little hop — your secrets sleep tight.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: simple encryption of creds' directly and concisely summarizes the main change: introducing a basic encryption system for credentials (API keys) across multiple files in the workspace engine.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch basic-encryption

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

}

func NewEncryption() Encryption {
keyStr := os.Getenv("CTRLPLANE_AES_256_KEY")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is a globals config object

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@apps/workspace-engine/pkg/secrets/secrets.go`:
- Around line 23-49: In NewEncryption, the log.Error calls inside the error
branches for aes.NewCipher and cipher.NewGCM use printf-style "%w" instead of
structured key/value pairs; change the two calls in NewEncryption that read
log.Error("failed to create cipher: %w", err) and log.Error("failed to create
GCM: %w", err) to structured form like log.Error("failed to create cipher",
"error", err) and log.Error("failed to create GCM", "error", err) so they match
the codebase pattern (references: NewEncryption, AES256Encryption,
NoopEncryption).

In `@apps/workspace-engine/pkg/workspace/store/job_agents.go`:
- Around line 41-49: The Upsert method on JobAgents currently swallows
encryption errors and silently skips the upsert; change JobAgents.Upsert so it
returns an error instead of returning early: call j.encryptCredentials(jobAgent)
and if it returns an error propagate that error to the caller (do not just log
and return), perform j.repo.JobAgents.Set(jobAgent.Id, jobAgent) and
j.store.changeset.RecordUpsert(jobAgent) only on success, and update any callers
to handle the returned error (including tests) so they can retry/alert as
appropriate; reference symbols: JobAgents.Upsert, encryptCredentials,
j.repo.JobAgents.Set, j.store.changeset.RecordUpsert.
- Around line 27-39: In JobAgents.encryptCredentials, avoid the unsafe type
assertion v.(string) when reading jobAgent.Config["apiKey"]; instead use the
comma-ok idiom to check the value and its type (e.g., val, ok := v.(string)) and
handle the non-string or nil case by returning a clear error (or skipping
encryption) before calling j.secrets.Encrypt; update the logic that assigns
jobAgentConfig[k] to use the encrypted string only when the cast succeeds.
- Around line 27-39: The encryptCredentials method in JobAgents blindly
re-encrypts jobAgent.Config["apiKey"] which causes double-encryption when an
already-encrypted JobAgent is re-upserted; update encryptCredentials to first
detect and handle already-encrypted values by either attempting to decrypt via
j.secrets.Decrypt and using the decrypted value (then re-encrypt once), or by
checking/adding a stable prefix/marker (e.g., "enc:") around values produced by
j.secrets.Encrypt and skipping encryption if the marker is present; ensure you
reference and use j.secrets.Encrypt and j.secrets.Decrypt (or add the marker
logic) inside JobAgents.encryptCredentials so Upsert/Get/HandleJobAgentUpdated
flow is idempotent.
🧹 Nitpick comments (5)
apps/workspace-engine/pkg/secrets/secrets.go (1)

23-33: Silent fallback to NoopEncryption stores credentials in plaintext.

When CTRLPLANE_AES_256_KEY is missing or invalid, the code logs an error but silently falls back to no-op, meaning API keys are stored unencrypted. In production, this could go unnoticed. Consider whether NewEncryption should return an error so callers can decide the behavior, or at least emit a more prominent warning (e.g., at startup health checks).

apps/workspace-engine/pkg/workspace/jobagents/argo/argoapp.go (3)

41-44: Variable secrets shadows the imported package name.

secrets := secrets.NewEncryption() reuses the package identifier as a local variable name. This works but can cause confusion if someone later tries to reference the package in this function. Same pattern exists in job_agents.go Line 13.

Proposed fix
 func NewArgoApplication(store *store.Store, verifications *verification.Manager) *ArgoApplication {
-	secrets := secrets.NewEncryption()
-	return &ArgoApplication{store: store, verifications: verifications, secrets: secrets}
+	enc := secrets.NewEncryption()
+	return &ArgoApplication{store: store, verifications: verifications, secrets: enc}
 }

97-103: decryptOrPlaintext silently swallows decryption errors — consider logging.

Falling back to plaintext on error is reasonable for migration (supporting both encrypted and unencrypted values). However, silently ignoring errors makes it hard to diagnose issues where a truly encrypted value fails to decrypt (e.g., key rotation). A debug/warn log would help operators.

Proposed fix
 func (a *ArgoApplication) decryptOrPlaintext(value string) string {
 	decrypted, err := a.secrets.Decrypt(value)
 	if err != nil {
+		log.Debug("failed to decrypt value, using as plaintext", "error", err)
 		return value
 	}
 	return decrypted
 }

35-48: Encryption instance created per-constructor rather than dependency-injected.

Both ArgoApplication and JobAgents independently call secrets.NewEncryption(), each reading the env var and constructing a cipher. This is fine for now but makes it harder to test (can't inject a mock) and creates implicit coupling to the environment. If this is intentional for simplicity, that's okay — just noting it for future consideration.

apps/workspace-engine/test/e2e/engine_job_agent_test.go (1)

675-717: Test verifies encryption but not the decryption round-trip.

The test confirms the stored apiKey differs from plaintext and that other fields remain unencrypted — good coverage for the encrypt path. However, it doesn't verify that the encrypted value can be successfully decrypted back to the original key (which is ultimately what matters for ArgoCD dispatch).

Consider adding an assertion that decrypts storedApiKey back to originalApiKey, or a separate test that exercises the full encrypt → store → retrieve → decrypt → use flow.

Comment on lines +23 to +49
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}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Check the file content and imports
head -50 apps/workspace-engine/pkg/secrets/secrets.go

Repository: ctrlplanedev/ctrlplane

Length of output: 1026


🏁 Script executed:

# Check for imports to identify the logging library
grep -n "import\|log\." apps/workspace-engine/pkg/secrets/secrets.go | head -30

Repository: ctrlplanedev/ctrlplane

Length of output: 329


🏁 Script executed:

# Search for charmbracelet/log usage patterns in the codebase
rg "charmbracelet/log" apps/workspace-engine/

Repository: ctrlplanedev/ctrlplane

Length of output: 5034


🌐 Web query:

charmbracelet/log v0.4.2 Error() API documentation structured logging

💡 Result:

In github.com/charmbracelet/log v0.4.2, the Error API is a leveled, structured log call that takes a message plus optional key/value pairs:

  • Package-level function: func Error(msg interface{}, keyvals ...interface{}) (pkg.go.dev)
  • Logger method: func (l *Logger) Error(msg interface{}, keyvals ...interface{}) (pkg.go.dev)

Structured logging shape

keyvals is a variadic list interpreted as pairs ("key1", value1, "key2", value2, ...). Example usage from the project docs:

err := fmt.Errorf("too much sugar")
log.Error("failed to bake cookies", "err", err)

(github.com)

To carry persistent structured context, you can create a sub-logger with fields:

log.With("err", err).Errorf("unable to start %s", "oven")

(github.com)

slog integration (and what v0.4.2 fixed)

*log.Logger also implements log/slog’s handler interface, so you can do:

handler := log.New(os.Stderr)
logger := slog.New(handler)
logger.Error("meow?")

(pkg.go.dev)

v0.4.2 specifically fixed an issue when using the JSON formatter as a slog handler with error-typed messages, ensuring errors are extracted/recognized correctly in that path. (gitlab.com)

Citations:


🏁 Script executed:

# Search for other log.Error calls in the codebase to see how they're used
rg "log\.Error\(" apps/workspace-engine/ -A 1 | head -60

Repository: ctrlplanedev/ctrlplane

Length of output: 4182


Fix log.Error calls to use structured key-value args instead of %w format strings.

charmbracelet/log uses structured logging. The %w format verb appears literally in output instead of formatting the error. Change both error logging calls to use the structured format:

log.Error("failed to create cipher", "error", err)

and:

log.Error("failed to create GCM", "error", err)

This matches the pattern used throughout the codebase.

🤖 Prompt for AI Agents
In `@apps/workspace-engine/pkg/secrets/secrets.go` around lines 23 - 49, In
NewEncryption, the log.Error calls inside the error branches for aes.NewCipher
and cipher.NewGCM use printf-style "%w" instead of structured key/value pairs;
change the two calls in NewEncryption that read log.Error("failed to create
cipher: %w", err) and log.Error("failed to create GCM: %w", err) to structured
form like log.Error("failed to create cipher", "error", err) and
log.Error("failed to create GCM", "error", err) so they match the codebase
pattern (references: NewEncryption, AES256Encryption, NoopEncryption).

Comment on lines +27 to 39
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
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unsafe type assertion on Line 31 will panic if apiKey is not a string.

If Config["apiKey"] is nil, a number, or any non-string type, v.(string) panics. Use the comma-ok idiom.

Proposed fix
 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))
+			strVal, ok := v.(string)
+			if !ok {
+				continue
+			}
+			encrypted, err := j.secrets.Encrypt(strVal)
 			if err != nil {
 				return err
 			}
 			jobAgentConfig[k] = encrypted
 		}
 	}
 	return nil
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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) encryptCredentials(jobAgent *oapi.JobAgent) error {
jobAgentConfig := jobAgent.Config
for k, v := range jobAgentConfig {
if k == "apiKey" {
strVal, ok := v.(string)
if !ok {
continue
}
encrypted, err := j.secrets.Encrypt(strVal)
if err != nil {
return err
}
jobAgentConfig[k] = encrypted
}
}
return nil
}
🤖 Prompt for AI Agents
In `@apps/workspace-engine/pkg/workspace/store/job_agents.go` around lines 27 -
39, In JobAgents.encryptCredentials, avoid the unsafe type assertion v.(string)
when reading jobAgent.Config["apiKey"]; instead use the comma-ok idiom to check
the value and its type (e.g., val, ok := v.(string)) and handle the non-string
or nil case by returning a clear error (or skipping encryption) before calling
j.secrets.Encrypt; update the logic that assigns jobAgentConfig[k] to use the
encrypted string only when the cast succeeds.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, find the JobAgents struct and Upsert method
rg -n "func.*Upsert" apps/workspace-engine/pkg/workspace/store/ --type=go -B2 -A10

Repository: ctrlplanedev/ctrlplane

Length of output: 29164


🏁 Script executed:

# Find the JobAgents type definition
rg -n "type JobAgents" apps/workspace-engine/pkg/workspace/store/ --type=go -B2 -A10

Repository: ctrlplanedev/ctrlplane

Length of output: 1156


🏁 Script executed:

# Search for where encryptCredentials is called
rg -n "encryptCredentials" apps/workspace-engine/pkg/workspace/store/ --type=go -B3 -A3

Repository: ctrlplanedev/ctrlplane

Length of output: 1316


🏁 Script executed:

# Find Get/Retrieve methods on JobAgents to understand retrieval pattern
rg -n "func.*Get" apps/workspace-engine/pkg/workspace/store/job_agents.go --type=go -B2 -A10

Repository: ctrlplanedev/ctrlplane

Length of output: 378


🏁 Script executed:

# Search for any decryption functions
rg -n "Decrypt" apps/workspace-engine/pkg/workspace/store/ --type=go

Repository: ctrlplanedev/ctrlplane

Length of output: 48


🏁 Script executed:

# Search for patterns where JobAgents are retrieved and potentially re-upseerted
# Look for Get followed by Upsert usage patterns
rg -n "jobAgent.*Get|Get.*jobAgent" apps/workspace-engine/ --type=go -B3 -A5

Repository: ctrlplanedev/ctrlplane

Length of output: 28457


🏁 Script executed:

# Check if encryptCredentials has any logic to detect already-encrypted values
cat apps/workspace-engine/pkg/workspace/store/job_agents.go

Repository: ctrlplanedev/ctrlplane

Length of output: 1582


🏁 Script executed:

# Search for any usage of JobAgents.Upsert to see what scenarios call it
rg -n "\.Upsert\(" apps/workspace-engine/ --type=go -B5 | grep -i "jobagent" -A5 -B5

Repository: ctrlplanedev/ctrlplane

Length of output: 50379


🏁 Script executed:

# Look for JobAgentUpdate handler and event processing
rg -n "JobAgentUpdate" apps/workspace-engine/ --type=go -B5 -A10

Repository: ctrlplanedev/ctrlplane

Length of output: 16394


🏁 Script executed:

# Search for handlers that process JobAgent events
rg -n "handler\.JobAgent" apps/workspace-engine/ --type=go -B3 -A8

Repository: ctrlplanedev/ctrlplane

Length of output: 36751


🏁 Script executed:

# Check if there's any test showing the update flow
cat apps/workspace-engine/test/e2e/engine_job_agent_test.go | grep -A 20 "Update job agent"

Repository: ctrlplanedev/ctrlplane

Length of output: 648


🏁 Script executed:

# Look at the specific test that handles apiKey encryption
sed -n '680,710p' apps/workspace-engine/test/e2e/engine_job_agent_test.go

Repository: ctrlplanedev/ctrlplane

Length of output: 892


🏁 Script executed:

# Check if there's any decrypt logic in the secrets package
rg -n "type Encryption" apps/workspace-engine/pkg/secrets/ --type=go -A10

Repository: ctrlplanedev/ctrlplane

Length of output: 842


🏁 Script executed:

# Check what happens when apiKey is used after retrieval
rg -n "Config\[\"apiKey\"\]" apps/workspace-engine/ --type=go -B3 -A3

Repository: ctrlplanedev/ctrlplane

Length of output: 1288


Double encryption risk confirmed: re-upsetering a JobAgent with encrypted credentials will double-encrypt the apiKey and render it unrecoverable.

The vulnerability is real. When a JobAgent is retrieved via Get(), the apiKey is returned in encrypted form without decryption. If that JobAgent is then updated and Upsert() is called again (as happens in HandleJobAgentUpdated at line 57 of pkg/events/handler/jobagents/jobagents.go), encryptCredentials will encrypt the already-encrypted value a second time, making it irrecoverable.

The encryptCredentials method has no protection against this—it blindly encrypts any apiKey field without checking if it's already encrypted. The Encryption interface includes both Encrypt and Decrypt methods, but retrieval never calls Decrypt.

Fix this by either:

  • Decrypting before re-encrypting in encryptCredentials to ensure idempotency, or
  • Adding a marker/prefix (e.g., enc:...) to encrypted values so the function can skip already-encrypted fields.
🤖 Prompt for AI Agents
In `@apps/workspace-engine/pkg/workspace/store/job_agents.go` around lines 27 -
39, The encryptCredentials method in JobAgents blindly re-encrypts
jobAgent.Config["apiKey"] which causes double-encryption when an
already-encrypted JobAgent is re-upserted; update encryptCredentials to first
detect and handle already-encrypted values by either attempting to decrypt via
j.secrets.Decrypt and using the decrypted value (then re-encrypt once), or by
checking/adding a stable prefix/marker (e.g., "enc:") around values produced by
j.secrets.Encrypt and skipping encryption if the marker is present; ensure you
reference and use j.secrets.Encrypt and j.secrets.Decrypt (or add the marker
logic) inside JobAgents.encryptCredentials so Upsert/Get/HandleJobAgentUpdated
flow is idempotent.

Comment on lines 41 to 49
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)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Silently skipping the upsert on encryption failure may cause data loss.

If encryption fails, the entire upsert is skipped without propagating the error to the caller. The job agent creation/update is silently dropped. Depending on the event-driven architecture, this event may never be retried, leading to a permanently missing agent.

Consider returning the error so the caller can decide how to handle it (e.g., retry, alert).

🤖 Prompt for AI Agents
In `@apps/workspace-engine/pkg/workspace/store/job_agents.go` around lines 41 -
49, The Upsert method on JobAgents currently swallows encryption errors and
silently skips the upsert; change JobAgents.Upsert so it returns an error
instead of returning early: call j.encryptCredentials(jobAgent) and if it
returns an error propagate that error to the caller (do not just log and
return), perform j.repo.JobAgents.Set(jobAgent.Id, jobAgent) and
j.store.changeset.RecordUpsert(jobAgent) only on success, and update any callers
to handle the returned error (including tests) so they can retry/alert as
appropriate; reference symbols: JobAgents.Upsert, encryptCredentials,
j.repo.JobAgents.Set, j.store.changeset.RecordUpsert.

}

ciphertext := e.gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be nice to prefix with aes256:<base64> just so its easier to understand whats going on

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a pretty command pattern

}

// Decrypt decrypts base64-encoded ciphertext and returns plaintext
func (e *AES256Encryption) Decrypt(ciphertext string) (string, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and remove the prefix here

@CLAassistant
Copy link

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

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.

3 participants