feat: AES-256-GCM encryption for per-location Cast API keys + MongoDB auth
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

- 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 <sidekx.ai@sds.dev>
This commit is contained in:
Head of Product & Engineering 2026-04-06 15:27:38 +02:00
parent dacaaa4c91
commit 4d8e1eb352
7 changed files with 288 additions and 19 deletions

View File

@ -1,10 +1,14 @@
PORT=3002 PORT=3002
BASE_URL=https://hl.cast.ph BASE_URL=https://hl.cast.ph
# nginx-proxy / Let's Encrypt
VIRTUAL_HOST=hl.cast.ph
LETSENCRYPT_EMAIL=ops@cast.ph
# GHL OAuth # GHL OAuth
GHL_CLIENT_ID= GHL_CLIENT_ID=
GHL_CLIENT_SECRET= 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_WEBHOOK_PUBLIC_KEY=
GHL_CONVERSATION_PROVIDER_ID= GHL_CONVERSATION_PROVIDER_ID=
@ -13,8 +17,18 @@ CAST_API_KEY=
CAST_API_URL=https://api.cast.ph CAST_API_URL=https://api.cast.ph
CAST_SENDER_ID= CAST_SENDER_ID=
# MongoDB # MongoDB — use a strong password; URI must include auth credentials
MONGO_URI=mongodb://mongo:27017/cast-ghl # Generate password: openssl rand -hex 24
MONGO_ROOT_USERNAME=castghl
MONGO_ROOT_PASSWORD=
MONGO_URI=mongodb://castghl:<password>@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= INBOUND_API_KEY=

View File

@ -15,6 +15,7 @@ import (
"git.sds.dev/CAST/cast-ghl-plugin/internal/cast" "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/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/ghl"
"git.sds.dev/CAST/cast-ghl-plugin/internal/store" "git.sds.dev/CAST/cast-ghl-plugin/internal/store"
) )
@ -35,7 +36,15 @@ func run() error {
ctx := context.Background() 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 { if err != nil {
return fmt.Errorf("mongodb: %w", err) return fmt.Errorf("mongodb: %w", err)
} }

View File

@ -26,13 +26,16 @@ services:
mongo: mongo:
image: mongo:7 image: mongo:7
# No ports exposed — only reachable by bridge on the internal network # 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: volumes:
- mongo-data:/data/db - mongo-data:/data/db
networks: networks:
- internal - internal
restart: unless-stopped restart: unless-stopped
healthcheck: 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 interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5

View File

@ -2,6 +2,7 @@ package config
import ( import (
"fmt" "fmt"
"log/slog"
"os" "os"
"strings" "strings"
) )
@ -18,6 +19,10 @@ type Config struct {
CastSenderID string CastSenderID string
MongoURI string MongoURI string
InboundAPIKey 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) { func Load() (*Config, error) {
@ -33,17 +38,18 @@ func Load() (*Config, error) {
CastSenderID: os.Getenv("CAST_SENDER_ID"), CastSenderID: os.Getenv("CAST_SENDER_ID"),
MongoURI: os.Getenv("MONGO_URI"), MongoURI: os.Getenv("MONGO_URI"),
InboundAPIKey: os.Getenv("INBOUND_API_KEY"), InboundAPIKey: os.Getenv("INBOUND_API_KEY"),
CredentialsEncryptionKey: os.Getenv("CREDENTIALS_ENCRYPTION_KEY"),
} }
var missing []string var missing []string
required := map[string]string{ required := map[string]string{
"BASE_URL": c.BaseURL, "BASE_URL": c.BaseURL,
"GHL_CLIENT_ID": c.GHLClientID, "GHL_CLIENT_ID": c.GHLClientID,
"GHL_CLIENT_SECRET": c.GHLClientSecret, "GHL_CLIENT_SECRET": c.GHLClientSecret,
"GHL_WEBHOOK_PUBLIC_KEY": c.GHLWebhookPublicKey, "GHL_WEBHOOK_PUBLIC_KEY": c.GHLWebhookPublicKey,
"GHL_CONVERSATION_PROVIDER_ID": c.GHLConversationProviderID, "GHL_CONVERSATION_PROVIDER_ID": c.GHLConversationProviderID,
"CAST_API_KEY": c.CastAPIKey, "CAST_API_KEY": c.CastAPIKey,
"MONGO_URI": c.MongoURI, "MONGO_URI": c.MongoURI,
} }
for key, val := range required { for key, val := range required {
if val == "" { if val == "" {
@ -53,6 +59,11 @@ func Load() (*Config, error) {
if len(missing) > 0 { if len(missing) > 0 {
return nil, fmt.Errorf("missing required environment variables: %s", strings.Join(missing, ", ")) 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 return c, nil
} }

83
internal/crypto/aes.go Normal file
View File

@ -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:<hex(nonce+ciphertext+tag)>".
// 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
}

View File

@ -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)
}
})
}
}

View File

@ -3,6 +3,8 @@ package store
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"log/slog"
"time" "time"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
@ -21,16 +23,27 @@ type TokenRecord struct {
ExpiresAt time.Time `bson:"expires_at"` ExpiresAt time.Time `bson:"expires_at"`
InstalledAt time.Time `bson:"installed_at"` InstalledAt time.Time `bson:"installed_at"`
UpdatedAt time.Time `bson:"updated_at"` UpdatedAt time.Time `bson:"updated_at"`
SenderID string `bson:"sender_id,omitempty"` // per-location Cast sender ID; overrides global default 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 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 { type Store struct {
client *mongo.Client client *mongo.Client
collection *mongo.Collection collection *mongo.Collection
cipher Cipher // nil = no encryption (plain text storage)
} }
func NewStore(ctx context.Context, uri string) (*Store, error) { 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) ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel() defer cancel()
@ -52,14 +65,47 @@ func NewStore(ctx context.Context, uri string) (*Store, error) {
return nil, err 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 { 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}} filter := bson.D{{Key: "location_id", Value: record.LocationID}}
opts := options.Replace().SetUpsert(true) opts := options.Replace().SetUpsert(true)
_, err := s.collection.ReplaceOne(ctx, filter, record, opts) _, err = s.collection.ReplaceOne(ctx, filter, toStore, opts)
return err return err
} }
@ -73,6 +119,7 @@ func (s *Store) GetToken(ctx context.Context, locationID string) (*TokenRecord,
if err != nil { if err != nil {
return nil, err return nil, err
} }
record.CastAPIKey = s.decryptKey(record.CastAPIKey)
return &record, nil 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. // 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). // 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 { 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}} filter := bson.D{{Key: "location_id", Value: locationID}}
update := bson.D{{Key: "$set", Value: bson.D{ update := bson.D{{Key: "$set", Value: bson.D{
{Key: "sender_id", Value: senderID}, {Key: "sender_id", Value: senderID},
{Key: "cast_api_key", Value: castAPIKey}, {Key: "cast_api_key", Value: encKey},
{Key: "updated_at", Value: time.Now()}, {Key: "updated_at", Value: time.Now()},
}}} }}}
res, err := s.collection.UpdateOne(ctx, filter, update) 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 { if err := cursor.All(ctx, &records); err != nil {
return nil, err return nil, err
} }
for _, rec := range records {
rec.CastAPIKey = s.decryptKey(rec.CastAPIKey)
}
return records, nil return records, nil
} }