-
Notifications
You must be signed in to change notification settings - Fork 15
feat: simple encryption of creds #792
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
562de01
eef25a6
f320d32
944673b
5b9ecd6
da59453
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| package secrets | ||
|
|
||
| import ( | ||
| "crypto/aes" | ||
| "crypto/cipher" | ||
| "crypto/rand" | ||
| "encoding/base64" | ||
| "fmt" | ||
| "strings" | ||
| "workspace-engine/pkg/config" | ||
|
|
||
| "github.com/charmbracelet/log" | ||
| ) | ||
|
|
||
| const AES_256_PREFIX = "aes256:" | ||
|
|
||
| type Encryption interface { | ||
| Encrypt(plaintext string) (string, error) | ||
| Decrypt(ciphertext string) (string, error) | ||
| } | ||
|
|
||
| type AES256Encryption struct { | ||
| gcm cipher.AEAD | ||
| } | ||
|
|
||
| func NewEncryption() Encryption { | ||
| keyStr := config.Global.AES256Key | ||
| if keyStr == "" { | ||
| log.Error("AES_256_KEY is not set, using noop encryption") | ||
| return &NoopEncryption{} | ||
| } | ||
|
|
||
| if len(keyStr) != 32 { | ||
| log.Error("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", "error", err) | ||
| return &NoopEncryption{} | ||
| } | ||
|
|
||
| gcm, err := cipher.NewGCM(block) | ||
| if err != nil { | ||
| log.Error("failed to create GCM", "error", err) | ||
| return &NoopEncryption{} | ||
| } | ||
|
|
||
| return &AES256Encryption{gcm: gcm} | ||
| } | ||
|
|
||
| // 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 { | ||
| return "", fmt.Errorf("failed to generate nonce: %w", err) | ||
| } | ||
|
|
||
| ciphertext := e.gcm.Seal(nonce, nonce, []byte(plaintext), nil) | ||
| return AES_256_PREFIX + base64.StdEncoding.EncodeToString(ciphertext), nil | ||
| } | ||
|
|
||
| // Decrypt decrypts base64-encoded ciphertext (with aes256: prefix) and returns plaintext | ||
| func (e *AES256Encryption) Decrypt(ciphertext string) (string, error) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. and remove the prefix here |
||
| 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) | ||
| } | ||
|
|
||
| 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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,23 +2,57 @@ package store | |
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "strings" | ||
| "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" { | ||
| 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 | ||
| } | ||
| 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) | ||
| } | ||
|
Comment on lines
50
to
58
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 |
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Silent fallback to
NoopEncryptionon misconfiguration stores credentials in plaintext.If
AES_256_KEYis set but has the wrong length, or cipher setup fails, the system silently falls back to no encryption. An operator may believe encryption is active when it isn't. Consider making this a fatal error (at least when the key is set but invalid), reserving the noop fallback only for when the key is intentionally omitted.Proposed fix (fail hard on invalid key, noop only when unset)
func NewEncryption() Encryption { keyStr := config.Global.AES256Key if keyStr == "" { - log.Error("AES_256_KEY is not set, using noop encryption") + log.Warn("AES_256_KEY is not set, using noop encryption") return &NoopEncryption{} } if len(keyStr) != 32 { - log.Error("AES_256_KEY must be 32 bytes, using noop encryption") - return &NoopEncryption{} + log.Fatal("AES_256_KEY is set but must be exactly 32 bytes") } key := []byte(keyStr) block, err := aes.NewCipher(key) if err != nil { - log.Error("failed to create cipher", "error", err) - return &NoopEncryption{} + log.Fatal("failed to create AES cipher", "error", err) } gcm, err := cipher.NewGCM(block) if err != nil { - log.Error("failed to create GCM", "error", err) - return &NoopEncryption{} + log.Fatal("failed to create GCM cipher", "error", err) } return &AES256Encryption{gcm: gcm} }🤖 Prompt for AI Agents