feat: AES-256-GCM encryption for per-location Cast API keys + MongoDB auth
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
parent
dacaaa4c91
commit
4d8e1eb352
22
.env.example
22
.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:<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=
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,6 +38,7 @@ 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
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
83
internal/crypto/aes.go
Normal file
83
internal/crypto/aes.go
Normal 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
|
||||
}
|
||||
94
internal/crypto/aes_test.go
Normal file
94
internal/crypto/aes_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,8 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@ -22,15 +24,26 @@ type TokenRecord struct {
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user