Renames: - platform/ → workspace-server/ (Go module path stays as "platform" for external dep compat — will update after plugin module republish) - workspace-template/ → workspace/ Removed (moved to separate repos or deleted): - PLAN.md — internal roadmap (move to private project board) - HANDOFF.md, AGENTS.md — one-time internal session docs - .claude/ — gitignored entirely (local agent config) - infra/cloudflare-worker/ → Molecule-AI/molecule-tenant-proxy - org-templates/molecule-dev/ → standalone template repo - .mcp-eval/ → molecule-mcp-server repo - test-results/ — ephemeral, gitignored Security scrubbing: - Cloudflare account/zone/KV IDs → placeholders - Real EC2 IPs → <EC2_IP> in all docs - CF token prefix, Neon project ID, Fly app names → redacted - Langfuse dev credentials → parameterized - Personal runner username/machine name → generic Community files: - CONTRIBUTING.md — build, test, branch conventions - CODE_OF_CONDUCT.md — Contributor Covenant 2.1 All Dockerfiles, CI workflows, docker-compose, railway.toml, render.yaml, README, CLAUDE.md updated for new directory names. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
130 lines
4.5 KiB
Go
130 lines
4.5 KiB
Go
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:<base64-GCM-ciphertext>" (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
|
|
}
|