diff --git a/platform/internal/channels/manager.go b/platform/internal/channels/manager.go index 425bea3c..a26d7a80 100644 --- a/platform/internal/channels/manager.go +++ b/platform/internal/channels/manager.go @@ -105,15 +105,20 @@ func (m *Manager) Start(ctx context.Context) { // PausePollersForToken stops any pollers that share the given bot token, // then returns a resume function. Used during discovery to avoid Telegram's // "only one getUpdates at a time" 409 Conflict. +// +// #319: bot_token is stored encrypted in channel_config so we cannot match +// with SQL `channel_config->>'bot_token' = $1` anymore. Load all enabled +// channels, decrypt each, and compare the plaintext in Go. The cardinality +// is small (typically <10 enabled channels per install) so the extra work +// is negligible. func (m *Manager) PausePollersForToken(botToken string) func() { if botToken == "" { return func() {} } rows, err := db.DB.QueryContext(context.Background(), ` - SELECT id FROM workspace_channels - WHERE enabled = true AND channel_config->>'bot_token' = $1 - `, botToken) + SELECT id, channel_config FROM workspace_channels WHERE enabled = true + `) if err != nil { return func() {} } @@ -123,7 +128,19 @@ func (m *Manager) PausePollersForToken(botToken string) func() { m.mu.Lock() for rows.Next() { var id string - if rows.Scan(&id) == nil { + var configJSON []byte + if err := rows.Scan(&id, &configJSON); err != nil { + continue + } + var config map[string]interface{} + if err := json.Unmarshal(configJSON, &config); err != nil { + continue + } + if err := DecryptSensitiveFields(config); err != nil { + log.Printf("Channels: pause-pollers decrypt error for %s: %v", truncID(id), err) + continue + } + if token, _ := config["bot_token"].(string); token == botToken { if cancel, ok := m.pollers[id]; ok { cancel() delete(m.pollers, id) @@ -182,6 +199,14 @@ func (m *Manager) Reload(ctx context.Context) { } json.Unmarshal(configJSON, &ch.Config) json.Unmarshal(allowedJSON, &ch.AllowedUsers) + // #319: decrypt at the boundary between DB (ciphertext) and the + // in-memory config adapters consume. A decrypt failure logs and + // skips the channel — downstream getUpdates would fail anyway + // with a mangled token so fail-closed here is kinder to operators. + if err := DecryptSensitiveFields(ch.Config); err != nil { + log.Printf("Channels: reload decrypt error for %s: %v", truncID(ch.ID), err) + continue + } desired[ch.ID] = ch } @@ -436,6 +461,11 @@ func (m *Manager) loadChannel(ctx context.Context, channelID string) (ChannelRow } json.Unmarshal(configJSON, &ch.Config) json.Unmarshal(allowedJSON, &ch.AllowedUsers) + // #319: decrypt bot_token / webhook_secret — SendOutbound and adapter + // methods downstream read them as plaintext strings. + if err := DecryptSensitiveFields(ch.Config); err != nil { + return ch, fmt.Errorf("decrypt channel %s: %w", channelID, err) + } return ch, nil } diff --git a/platform/internal/channels/secret.go b/platform/internal/channels/secret.go new file mode 100644 index 00000000..836f33d1 --- /dev/null +++ b/platform/internal/channels/secret.go @@ -0,0 +1,129 @@ +package channels + +// Field-level encryption for sensitive channel_config values (#319). +// +// workspace_channels.channel_config is a JSONB column holding adapter-specific +// settings. Some fields are secret — Telegram bot tokens, webhook shared +// secrets — and must not sit in cleartext at the database layer where a +// backup leak or read-replica mis-grant would expose them. workspace_secrets +// already encrypts values with AES-256-GCM; this file mirrors that posture +// for channel_config so the security stance is consistent. +// +// Strategy: lazy field-level encryption with a version prefix. +// +// plaintext "123456:AA..." (legacy / pre-migration) +// ciphertext "ec1:" (new writes) +// +// On read, a missing "ec1:" prefix means the row predates the encryption +// rollout — return the value as-is (pass-through). On write, always encrypt. +// Rows upgrade lazily on the next PATCH/Create. An operator wishing to +// force-upgrade everything can re-save each channel via the Canvas Update +// button. +// +// Only `bot_token` and `webhook_secret` are considered secret. Other fields +// (chat_id, channel_name, enable_polling, etc.) stay in cleartext so the +// SQL-level `channel_config->>'chat_id'` lookups in the webhook receiver +// remain efficient. + +import ( + "encoding/base64" + "strings" + + "github.com/Molecule-AI/molecule-monorepo/platform/internal/crypto" +) + +// sensitiveFields is the set of channel_config keys that get encrypted at +// rest. Add a new key here to extend coverage — do NOT widen this to the +// whole config: it would break SQL field-access for non-secret keys like +// `chat_id` that the webhook receiver queries. +var sensitiveFields = []string{"bot_token", "webhook_secret"} + +// ciphertextPrefix marks values encrypted by EncryptSensitiveFields so +// DecryptSensitiveFields can tell "new encrypted value" from a legacy +// plaintext row. The string is intentionally distinctive — no real bot +// token begins with "ec1:". +const ciphertextPrefix = "ec1:" + +// EncryptSensitiveFields encrypts every known-sensitive value in config in +// place. Values that are already prefixed (already encrypted) are left +// untouched so a no-op re-save won't double-encrypt. Non-string values, +// empty strings, and unknown fields pass through unchanged. +// +// When SECRETS_ENCRYPTION_KEY is not configured (dev default), values are +// stored as plaintext — consistent with workspace_secrets' dev fallback. +func EncryptSensitiveFields(config map[string]interface{}) error { + if config == nil { + return nil + } + for _, field := range sensitiveFields { + raw, ok := config[field] + if !ok { + continue + } + s, ok := raw.(string) + if !ok || s == "" { + continue + } + if strings.HasPrefix(s, ciphertextPrefix) { + // already encrypted (idempotent re-save) + continue + } + if !crypto.IsEnabled() { + // Dev fallback: leave plaintext so local test setups without a + // key keep working. Prod boots with crypto.InitStrict which + // refuses to start without a key, so this branch is dev-only. + continue + } + ct, err := crypto.Encrypt([]byte(s)) + if err != nil { + return err + } + config[field] = ciphertextPrefix + base64.StdEncoding.EncodeToString(ct) + } + return nil +} + +// DecryptSensitiveFields is the inverse of EncryptSensitiveFields. Values +// without the ciphertext prefix are returned as-is (legacy plaintext rows). +// Values with the prefix are base64-decoded and run through AES-256-GCM. +// +// When SECRETS_ENCRYPTION_KEY is not configured but a prefixed value is +// encountered, that's an operator error (enabled encryption then disabled +// the key). Return the raw prefixed string in that case — the adapter will +// fail to authenticate with Telegram/Slack and the operator will see a +// clear "invalid bot token" message rather than a silent mis-decrypt. +func DecryptSensitiveFields(config map[string]interface{}) error { + if config == nil { + return nil + } + for _, field := range sensitiveFields { + raw, ok := config[field] + if !ok { + continue + } + s, ok := raw.(string) + if !ok || s == "" { + continue + } + if !strings.HasPrefix(s, ciphertextPrefix) { + // legacy plaintext row — pass through + continue + } + if !crypto.IsEnabled() { + // encryption-expected row but no key — leave encoded so callers + // fail loudly at Telegram rather than mis-decrypt. + continue + } + encoded := strings.TrimPrefix(s, ciphertextPrefix) + ct, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return err + } + pt, err := crypto.Decrypt(ct) + if err != nil { + return err + } + config[field] = string(pt) + } + return nil +} diff --git a/platform/internal/channels/secret_test.go b/platform/internal/channels/secret_test.go new file mode 100644 index 00000000..eb33585e --- /dev/null +++ b/platform/internal/channels/secret_test.go @@ -0,0 +1,138 @@ +package channels + +import ( + "strings" + "testing" + + "github.com/Molecule-AI/molecule-monorepo/platform/internal/crypto" +) + +// withTestEncryptionKey installs a deterministic 32-byte key for the +// duration of a test, then restores the previous state. Without this +// the tests would depend on ambient SECRETS_ENCRYPTION_KEY. +func withTestEncryptionKey(t *testing.T) { + t.Helper() + // Base64 of 32 zero bytes = "AAAA..." (44 chars). Matches the loader's + // base64 path — the raw 32-byte path requires a string that is not + // decodable as base64, which is surprisingly hard to construct. + t.Setenv("SECRETS_ENCRYPTION_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + crypto.ResetForTesting() + crypto.Init() + t.Cleanup(func() { + crypto.ResetForTesting() + }) +} + +func TestEncryptSensitiveFields_RoundTrip(t *testing.T) { + withTestEncryptionKey(t) + + cfg := map[string]interface{}{ + "bot_token": "123456:telegram-bot-token", + "chat_id": "-100999", // non-sensitive, untouched + "webhook_secret": "hmac-shared-key", // second known-sensitive field + } + + if err := EncryptSensitiveFields(cfg); err != nil { + t.Fatalf("encrypt: %v", err) + } + + if tok, _ := cfg["bot_token"].(string); !strings.HasPrefix(tok, ciphertextPrefix) { + t.Errorf("bot_token not encrypted: got %q", tok) + } + if sec, _ := cfg["webhook_secret"].(string); !strings.HasPrefix(sec, ciphertextPrefix) { + t.Errorf("webhook_secret not encrypted: got %q", sec) + } + if chat, _ := cfg["chat_id"].(string); chat != "-100999" { + t.Errorf("chat_id modified: got %q", chat) + } + + if err := DecryptSensitiveFields(cfg); err != nil { + t.Fatalf("decrypt: %v", err) + } + + if got, _ := cfg["bot_token"].(string); got != "123456:telegram-bot-token" { + t.Errorf("bot_token round-trip mismatch: got %q", got) + } + if got, _ := cfg["webhook_secret"].(string); got != "hmac-shared-key" { + t.Errorf("webhook_secret round-trip mismatch: got %q", got) + } +} + +func TestEncryptSensitiveFields_Idempotent(t *testing.T) { + withTestEncryptionKey(t) + + cfg := map[string]interface{}{"bot_token": "abc"} + if err := EncryptSensitiveFields(cfg); err != nil { + t.Fatalf("first encrypt: %v", err) + } + first, _ := cfg["bot_token"].(string) + + if err := EncryptSensitiveFields(cfg); err != nil { + t.Fatalf("second encrypt: %v", err) + } + second, _ := cfg["bot_token"].(string) + + if first != second { + t.Errorf("idempotent encrypt should not re-wrap: first=%q second=%q", first, second) + } +} + +func TestDecryptSensitiveFields_LegacyPlaintextPassesThrough(t *testing.T) { + // Legacy row predates #319 — no ciphertext prefix. + withTestEncryptionKey(t) + + cfg := map[string]interface{}{"bot_token": "legacy-plaintext-value"} + if err := DecryptSensitiveFields(cfg); err != nil { + t.Fatalf("decrypt: %v", err) + } + if got, _ := cfg["bot_token"].(string); got != "legacy-plaintext-value" { + t.Errorf("legacy plaintext was mangled: got %q", got) + } +} + +func TestEncryptSensitiveFields_DevFallback_NoKey(t *testing.T) { + // No key set — dev behaviour matches workspace_secrets: store plaintext. + t.Setenv("SECRETS_ENCRYPTION_KEY", "") + crypto.ResetForTesting() + crypto.Init() + t.Cleanup(crypto.ResetForTesting) + + cfg := map[string]interface{}{"bot_token": "dev-token"} + if err := EncryptSensitiveFields(cfg); err != nil { + t.Fatalf("encrypt: %v", err) + } + if got, _ := cfg["bot_token"].(string); got != "dev-token" { + t.Errorf("dev fallback should leave plaintext: got %q", got) + } +} + +func TestEncryptSensitiveFields_SkipsEmptyAndNonString(t *testing.T) { + withTestEncryptionKey(t) + + cfg := map[string]interface{}{ + "bot_token": "", // empty + "webhook_secret": 12345, // non-string + "unrelated": "ignore", // not in sensitiveFields + } + if err := EncryptSensitiveFields(cfg); err != nil { + t.Fatalf("encrypt: %v", err) + } + if got, _ := cfg["bot_token"].(string); got != "" { + t.Errorf("empty bot_token should stay empty: got %q", got) + } + if got, _ := cfg["webhook_secret"].(int); got != 12345 { + t.Errorf("non-string webhook_secret should be untouched: got %v", cfg["webhook_secret"]) + } + if got, _ := cfg["unrelated"].(string); got != "ignore" { + t.Errorf("unrelated field should be untouched: got %q", got) + } +} + +func TestEncryptSensitiveFields_NilConfig(t *testing.T) { + if err := EncryptSensitiveFields(nil); err != nil { + t.Errorf("nil config: expected no error, got %v", err) + } + if err := DecryptSensitiveFields(nil); err != nil { + t.Errorf("nil config: expected no error, got %v", err) + } +} diff --git a/platform/internal/handlers/channels.go b/platform/internal/handlers/channels.go index 711e6fc7..bcb7de73 100644 --- a/platform/internal/handlers/channels.go +++ b/platform/internal/handlers/channels.go @@ -61,6 +61,13 @@ func (h *ChannelHandler) List(c *gin.Context) { var config map[string]interface{} json.Unmarshal(configJSON, &config) + // #319: decrypt sensitive fields first so the mask operates on + // plaintext (first-4 / last-4 of the real token, not the ciphertext + // prefix). Decrypt errors are logged but non-fatal — List must keep + // returning the rest of the channel even if one field is corrupt. + if err := channels.DecryptSensitiveFields(config); err != nil { + log.Printf("Channels: decrypt config on list for channel %s: %v", id, err) + } // Mask bot_token in list response if _, ok := config["bot_token"]; ok { token, _ := config["bot_token"].(string) @@ -126,6 +133,15 @@ func (h *ChannelHandler) Create(c *gin.Context) { return } + // #319: encrypt sensitive fields (bot_token, webhook_secret) before + // persisting so a DB read/backup leak can't recover the credentials. + // Validation above ran against plaintext; storage is ciphertext. + if err := channels.EncryptSensitiveFields(body.Config); err != nil { + log.Printf("Channels: encrypt config failed for workspace %s: %v", workspaceID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "encrypt failed"}) + return + } + configJSON, _ := json.Marshal(body.Config) allowedJSON, _ := json.Marshal(body.AllowedUsers) enabled := true @@ -174,6 +190,14 @@ func (h *ChannelHandler) Update(c *gin.Context) { // COALESCE-based update var configArg, allowedArg interface{} if body.Config != nil { + // #319: re-encrypt sensitive fields on every config update — the + // PATCH body carries plaintext (client already had them plaintext in + // List response's unmasked path or typed fresh). + if err := channels.EncryptSensitiveFields(body.Config); err != nil { + log.Printf("Channels: encrypt update for workspace %s: %v", workspaceID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "encrypt failed"}) + return + } j, _ := json.Marshal(body.Config) configArg = string(j) } @@ -391,6 +415,13 @@ func (h *ChannelHandler) Webhook(c *gin.Context) { } json.Unmarshal(configJSON, &row.Config) json.Unmarshal(allowedJSON, &row.AllowedUsers) + // #319: decrypt sensitive fields before comparing webhook_secret / + // using bot_token downstream. Skip rows whose decrypt fails so a + // single corrupt channel cannot block webhooks for all others. + if err := channels.DecryptSensitiveFields(row.Config); err != nil { + log.Printf("Channels: decrypt webhook row %s: %v", row.ID, err) + continue + } // Verify webhook secret_token if the channel has one configured if expectedSecret, _ := row.Config["webhook_secret"].(string); expectedSecret != "" {