From 4d8e1eb352ef52eebec965b5604509fc7127ff4e Mon Sep 17 00:00:00 2001 From: Head of Product & Engineering Date: Mon, 6 Apr 2026 15:27:38 +0200 Subject: [PATCH] feat: AES-256-GCM encryption for per-location Cast API keys + MongoDB auth - Add internal/crypto package: AES-256-GCM encrypt/decrypt with migration passthrough for existing plain-text records (no "enc:" prefix = plain text) - Store.NewStoreWithCipher injects cipher; SaveToken/UpdateLocationConfig encrypt cast_api_key before write; GetToken/ListTokens decrypt on read - Add CREDENTIALS_ENCRYPTION_KEY env var (64-hex / 32-byte); warns if unset - Add MongoDB authentication: MONGO_ROOT_USERNAME / MONGO_ROOT_PASSWORD via docker-compose MONGO_INITDB_ROOT_USERNAME/PASSWORD; MONGO_URI now requires credentials in .env.example - Update .env.example with generation instructions for all secrets Co-Authored-By: SideKx --- .env.example | 22 +++++++-- cmd/server/main.go | 11 ++++- docker-compose.yaml | 5 +- internal/config/config.go | 25 +++++++--- internal/crypto/aes.go | 83 ++++++++++++++++++++++++++++++++ internal/crypto/aes_test.go | 94 +++++++++++++++++++++++++++++++++++++ internal/store/mongo.go | 67 +++++++++++++++++++++++--- 7 files changed, 288 insertions(+), 19 deletions(-) create mode 100644 internal/crypto/aes.go create mode 100644 internal/crypto/aes_test.go diff --git a/.env.example b/.env.example index 4283762..b73c655 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,14 @@ PORT=3002 BASE_URL=https://hl.cast.ph +# nginx-proxy / Let's Encrypt +VIRTUAL_HOST=hl.cast.ph +LETSENCRYPT_EMAIL=ops@cast.ph + # GHL OAuth GHL_CLIENT_ID= GHL_CLIENT_SECRET= -# RSA public key from GHL docs (static, not per-app). Paste the full PEM block. +# Ed25519 public key from GHL Marketplace app settings (PKIX PEM). Paste the full PEM block. GHL_WEBHOOK_PUBLIC_KEY= GHL_CONVERSATION_PROVIDER_ID= @@ -13,8 +17,18 @@ CAST_API_KEY= CAST_API_URL=https://api.cast.ph CAST_SENDER_ID= -# MongoDB -MONGO_URI=mongodb://mongo:27017/cast-ghl +# MongoDB — use a strong password; URI must include auth credentials +# Generate password: openssl rand -hex 24 +MONGO_ROOT_USERNAME=castghl +MONGO_ROOT_PASSWORD= +MONGO_URI=mongodb://castghl:@mongo:27017/cast-ghl?authSource=admin -# Inbound (Phase 2) +# AES-256 key for encrypting per-location Cast API keys at rest in MongoDB. +# Generate: openssl rand -hex 32 +# WARNING: if this key is lost without migrating records first, per-location +# API keys stored in MongoDB will be unreadable. Back this up securely. +CREDENTIALS_ENCRYPTION_KEY= + +# Admin API shared secret (protects /api/admin/*) +# Generate: openssl rand -hex 32 INBOUND_API_KEY= diff --git a/cmd/server/main.go b/cmd/server/main.go index d093b0b..71ccb63 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -15,6 +15,7 @@ import ( "git.sds.dev/CAST/cast-ghl-plugin/internal/cast" "git.sds.dev/CAST/cast-ghl-plugin/internal/config" + "git.sds.dev/CAST/cast-ghl-plugin/internal/crypto" "git.sds.dev/CAST/cast-ghl-plugin/internal/ghl" "git.sds.dev/CAST/cast-ghl-plugin/internal/store" ) @@ -35,7 +36,15 @@ func run() error { ctx := context.Background() - s, err := store.NewStore(ctx, cfg.MongoURI) + var cipher *crypto.Cipher + if cfg.CredentialsEncryptionKey != "" { + cipher, err = crypto.NewCipher(cfg.CredentialsEncryptionKey) + if err != nil { + return fmt.Errorf("credentials cipher: %w", err) + } + } + + s, err := store.NewStoreWithCipher(ctx, cfg.MongoURI, cipher) if err != nil { return fmt.Errorf("mongodb: %w", err) } diff --git a/docker-compose.yaml b/docker-compose.yaml index 58c3dc2..9327dc0 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -26,13 +26,16 @@ services: mongo: image: mongo:7 # No ports exposed — only reachable by bridge on the internal network + environment: + - MONGO_INITDB_ROOT_USERNAME=${MONGO_ROOT_USERNAME} + - MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD} volumes: - mongo-data:/data/db networks: - internal restart: unless-stopped healthcheck: - test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')", "-u", "${MONGO_ROOT_USERNAME}", "-p", "${MONGO_ROOT_PASSWORD}", "--authenticationDatabase", "admin"] interval: 10s timeout: 5s retries: 5 diff --git a/internal/config/config.go b/internal/config/config.go index 9157d5f..1666d35 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "log/slog" "os" "strings" ) @@ -18,6 +19,10 @@ type Config struct { CastSenderID string MongoURI string InboundAPIKey string + // CredentialsEncryptionKey is a 64-hex-char (32-byte) AES-256 key used to + // encrypt per-location Cast API keys at rest in MongoDB. + // Optional: if unset, keys are stored in plain text (not recommended for production). + CredentialsEncryptionKey string } func Load() (*Config, error) { @@ -33,17 +38,18 @@ func Load() (*Config, error) { CastSenderID: os.Getenv("CAST_SENDER_ID"), MongoURI: os.Getenv("MONGO_URI"), InboundAPIKey: os.Getenv("INBOUND_API_KEY"), + CredentialsEncryptionKey: os.Getenv("CREDENTIALS_ENCRYPTION_KEY"), } var missing []string required := map[string]string{ - "BASE_URL": c.BaseURL, - "GHL_CLIENT_ID": c.GHLClientID, - "GHL_CLIENT_SECRET": c.GHLClientSecret, - "GHL_WEBHOOK_PUBLIC_KEY": c.GHLWebhookPublicKey, - "GHL_CONVERSATION_PROVIDER_ID": c.GHLConversationProviderID, - "CAST_API_KEY": c.CastAPIKey, - "MONGO_URI": c.MongoURI, + "BASE_URL": c.BaseURL, + "GHL_CLIENT_ID": c.GHLClientID, + "GHL_CLIENT_SECRET": c.GHLClientSecret, + "GHL_WEBHOOK_PUBLIC_KEY": c.GHLWebhookPublicKey, + "GHL_CONVERSATION_PROVIDER_ID": c.GHLConversationProviderID, + "CAST_API_KEY": c.CastAPIKey, + "MONGO_URI": c.MongoURI, } for key, val := range required { if val == "" { @@ -53,6 +59,11 @@ func Load() (*Config, error) { if len(missing) > 0 { return nil, fmt.Errorf("missing required environment variables: %s", strings.Join(missing, ", ")) } + + if c.CredentialsEncryptionKey == "" { + slog.Warn("CREDENTIALS_ENCRYPTION_KEY is not set — per-location Cast API keys will be stored in plain text") + } + return c, nil } diff --git a/internal/crypto/aes.go b/internal/crypto/aes.go new file mode 100644 index 0000000..d9b558c --- /dev/null +++ b/internal/crypto/aes.go @@ -0,0 +1,83 @@ +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "strings" +) + +const encPrefix = "enc:" + +// Cipher encrypts and decrypts short string values using AES-256-GCM. +// The encrypted format is: "enc:" + hex(nonce + ciphertext + tag). +// +// Values that do not start with "enc:" are treated as plain text — +// this allows safe migration of existing unencrypted records. +type Cipher struct { + gcm cipher.AEAD +} + +// NewCipher creates a Cipher from a 64-hex-character (32-byte) key. +func NewCipher(keyHex string) (*Cipher, error) { + key, err := hex.DecodeString(keyHex) + if err != nil { + return nil, fmt.Errorf("crypto: invalid hex key: %w", err) + } + if len(key) != 32 { + return nil, fmt.Errorf("crypto: key must be 32 bytes (64 hex chars), got %d bytes", len(key)) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("crypto: failed to create AES cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("crypto: failed to create GCM: %w", err) + } + return &Cipher{gcm: gcm}, nil +} + +// Encrypt encrypts plaintext and returns "enc:". +// Empty input is returned as-is (no encryption needed for empty/unset fields). +func (c *Cipher) Encrypt(plaintext string) (string, error) { + if plaintext == "" { + return "", nil + } + nonce := make([]byte, c.gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("crypto: failed to generate nonce: %w", err) + } + ciphertext := c.gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return encPrefix + hex.EncodeToString(ciphertext), nil +} + +// Decrypt decrypts a value produced by Encrypt. +// If the value does not start with "enc:", it is returned unchanged (plain text migration path). +func (c *Cipher) Decrypt(value string) (string, error) { + if value == "" { + return "", nil + } + if !strings.HasPrefix(value, encPrefix) { + // Plain text — not yet encrypted; return as-is. + return value, nil + } + data, err := hex.DecodeString(strings.TrimPrefix(value, encPrefix)) + if err != nil { + return "", fmt.Errorf("crypto: malformed ciphertext hex: %w", err) + } + nonceSize := c.gcm.NonceSize() + if len(data) < nonceSize { + return "", errors.New("crypto: ciphertext too short") + } + nonce, ciphertext := data[:nonceSize], data[nonceSize:] + plaintext, err := c.gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", fmt.Errorf("crypto: decryption failed: %w", err) + } + return string(plaintext), nil +} diff --git a/internal/crypto/aes_test.go b/internal/crypto/aes_test.go new file mode 100644 index 0000000..31a7fe6 --- /dev/null +++ b/internal/crypto/aes_test.go @@ -0,0 +1,94 @@ +package crypto + +import ( + "strings" + "testing" +) + +const testKeyHex = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + +func TestEncryptDecryptRoundtrip(t *testing.T) { + c, err := NewCipher(testKeyHex) + if err != nil { + t.Fatalf("NewCipher: %v", err) + } + + plaintext := "cast_abc123def456" + encrypted, err := c.Encrypt(plaintext) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + if !strings.HasPrefix(encrypted, encPrefix) { + t.Errorf("encrypted value missing %q prefix: %q", encPrefix, encrypted) + } + if encrypted == plaintext { + t.Errorf("encrypted value equals plaintext — no encryption occurred") + } + + decrypted, err := c.Decrypt(encrypted) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + if decrypted != plaintext { + t.Errorf("roundtrip mismatch: got %q, want %q", decrypted, plaintext) + } +} + +func TestEncryptProducesUniqueNonce(t *testing.T) { + c, err := NewCipher(testKeyHex) + if err != nil { + t.Fatalf("NewCipher: %v", err) + } + a, _ := c.Encrypt("same-value") + b, _ := c.Encrypt("same-value") + if a == b { + t.Error("two encryptions of the same plaintext produced identical ciphertext (nonce reuse)") + } +} + +func TestDecryptPlainText(t *testing.T) { + c, err := NewCipher(testKeyHex) + if err != nil { + t.Fatalf("NewCipher: %v", err) + } + // Plain text (no "enc:" prefix) should pass through unchanged for migration. + plain := "legacy_unencrypted_key" + result, err := c.Decrypt(plain) + if err != nil { + t.Fatalf("Decrypt plain text: %v", err) + } + if result != plain { + t.Errorf("plain text passthrough: got %q, want %q", result, plain) + } +} + +func TestEncryptEmptyString(t *testing.T) { + c, err := NewCipher(testKeyHex) + if err != nil { + t.Fatalf("NewCipher: %v", err) + } + enc, err := c.Encrypt("") + if err != nil { + t.Fatalf("Encrypt empty: %v", err) + } + if enc != "" { + t.Errorf("encrypt empty: expected empty result, got %q", enc) + } +} + +func TestNewCipherInvalidKey(t *testing.T) { + cases := []struct{ name, key string }{ + {"not hex", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"}, + {"too short", "0102030405060708"}, + {"too long", testKeyHex + "ff"}, + {"empty", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := NewCipher(tc.key) + if err == nil { + t.Errorf("expected error for key %q, got nil", tc.key) + } + }) + } +} diff --git a/internal/store/mongo.go b/internal/store/mongo.go index f4fe8b6..9aba6af 100644 --- a/internal/store/mongo.go +++ b/internal/store/mongo.go @@ -3,6 +3,8 @@ package store import ( "context" "errors" + "fmt" + "log/slog" "time" "go.mongodb.org/mongo-driver/v2/bson" @@ -21,16 +23,27 @@ type TokenRecord struct { ExpiresAt time.Time `bson:"expires_at"` InstalledAt time.Time `bson:"installed_at"` UpdatedAt time.Time `bson:"updated_at"` - SenderID string `bson:"sender_id,omitempty"` // per-location Cast sender ID; overrides global default - CastAPIKey string `bson:"cast_api_key,omitempty"` // per-location Cast API key; overrides global CAST_API_KEY + SenderID string `bson:"sender_id,omitempty"` // per-location Cast sender ID; overrides global default + CastAPIKey string `bson:"cast_api_key,omitempty"` // per-location Cast API key; overrides global CAST_API_KEY (stored encrypted when cipher is set) +} + +// Cipher encrypts and decrypts string values at rest. +type Cipher interface { + Encrypt(plaintext string) (string, error) + Decrypt(value string) (string, error) } type Store struct { client *mongo.Client collection *mongo.Collection + cipher Cipher // nil = no encryption (plain text storage) } func NewStore(ctx context.Context, uri string) (*Store, error) { + return NewStoreWithCipher(ctx, uri, nil) +} + +func NewStoreWithCipher(ctx context.Context, uri string, cipher Cipher) (*Store, error) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() @@ -52,14 +65,47 @@ func NewStore(ctx context.Context, uri string) (*Store, error) { return nil, err } - return &Store{client: client, collection: col}, nil + return &Store{client: client, collection: col, cipher: cipher}, nil +} + +// encryptKey encrypts a Cast API key before storage. No-op if cipher is nil or key is empty. +func (s *Store) encryptKey(key string) (string, error) { + if s.cipher == nil || key == "" { + return key, nil + } + encrypted, err := s.cipher.Encrypt(key) + if err != nil { + return "", fmt.Errorf("store: encrypt cast_api_key: %w", err) + } + return encrypted, nil +} + +// decryptKey decrypts a stored Cast API key. No-op if cipher is nil or key is empty. +func (s *Store) decryptKey(key string) string { + if s.cipher == nil || key == "" { + return key + } + plaintext, err := s.cipher.Decrypt(key) + if err != nil { + // Log but don't fail — return raw value so the caller can surface the error gracefully. + slog.Error("store: failed to decrypt cast_api_key", "err", err) + return "" + } + return plaintext } func (s *Store) SaveToken(ctx context.Context, record *TokenRecord) error { - record.UpdatedAt = time.Now() + encKey, err := s.encryptKey(record.CastAPIKey) + if err != nil { + return err + } + toStore := *record + toStore.CastAPIKey = encKey + toStore.UpdatedAt = time.Now() + filter := bson.D{{Key: "location_id", Value: record.LocationID}} opts := options.Replace().SetUpsert(true) - _, err := s.collection.ReplaceOne(ctx, filter, record, opts) + _, err = s.collection.ReplaceOne(ctx, filter, toStore, opts) return err } @@ -73,6 +119,7 @@ func (s *Store) GetToken(ctx context.Context, locationID string) (*TokenRecord, if err != nil { return nil, err } + record.CastAPIKey = s.decryptKey(record.CastAPIKey) return &record, nil } @@ -91,10 +138,15 @@ func (s *Store) UpdateToken(ctx context.Context, locationID, accessToken, refres // UpdateLocationConfig sets the per-location Cast sender ID and API key. // Either field may be empty to clear it (falling back to the global default). func (s *Store) UpdateLocationConfig(ctx context.Context, locationID, senderID, castAPIKey string) error { + encKey, err := s.encryptKey(castAPIKey) + if err != nil { + return err + } + filter := bson.D{{Key: "location_id", Value: locationID}} update := bson.D{{Key: "$set", Value: bson.D{ {Key: "sender_id", Value: senderID}, - {Key: "cast_api_key", Value: castAPIKey}, + {Key: "cast_api_key", Value: encKey}, {Key: "updated_at", Value: time.Now()}, }}} res, err := s.collection.UpdateOne(ctx, filter, update) @@ -118,6 +170,9 @@ func (s *Store) ListTokens(ctx context.Context) ([]*TokenRecord, error) { if err := cursor.All(ctx, &records); err != nil { return nil, err } + for _, rec := range records { + rec.CastAPIKey = s.decryptKey(rec.CastAPIKey) + } return records, nil }