package services import ( "context" "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/x509" "database/sql" "encoding/base64" "encoding/json" "encoding/pem" "fmt" "os" "sync" ) type CredentialsService struct { DB *sql.DB // Cache for decrypted keys cache map[string]string cacheMutex sync.RWMutex } func NewCredentialsService(db *sql.DB) *CredentialsService { return &CredentialsService{ DB: db, cache: make(map[string]string), } } // SaveCredentials saves the encrypted payload for a service func (s *CredentialsService) SaveCredentials(ctx context.Context, serviceName, encryptedPayload, updatedBy string) error { query := ` INSERT INTO external_services_credentials (service_name, encrypted_payload, updated_by, updated_at) VALUES ($1, $2, NULLIF($3, '')::uuid, NOW()) ON CONFLICT (service_name) DO UPDATE SET encrypted_payload = EXCLUDED.encrypted_payload, updated_by = EXCLUDED.updated_by, updated_at = NOW() ` _, err := s.DB.ExecContext(ctx, query, serviceName, encryptedPayload, updatedBy) if err != nil { return err } // Invalidate cache s.cacheMutex.Lock() delete(s.cache, serviceName) s.cacheMutex.Unlock() return nil } // GetDecryptedKey retrieves and decrypts the key for a service func (s *CredentialsService) GetDecryptedKey(ctx context.Context, serviceName string) (string, error) { // Check cache first // Cache DISABLED to support external updates from Backoffice /* s.cacheMutex.RLock() if val, ok := s.cache[serviceName]; ok { s.cacheMutex.RUnlock() return val, nil } s.cacheMutex.RUnlock() */ // Fetch from DB var encryptedPayload string query := `SELECT encrypted_payload FROM external_services_credentials WHERE service_name = $1` err := s.DB.QueryRowContext(ctx, query, serviceName).Scan(&encryptedPayload) if err == sql.ErrNoRows { return "", fmt.Errorf("credentials for service %s not found", serviceName) } if err != nil { return "", err } // Decrypt decrypted, err := s.decryptPayload(encryptedPayload) if err != nil { return "", fmt.Errorf("failed to decrypt credentials: %w", err) } // Update cache s.cacheMutex.Lock() s.cache[serviceName] = decrypted s.cacheMutex.Unlock() return decrypted, nil } func (s *CredentialsService) decryptPayload(encryptedPayload string) (string, error) { // 1. Decode Private Key from Env rawPrivateKey, err := base64.StdEncoding.DecodeString(getEnvRSAKey()) if err != nil { return "", fmt.Errorf("failed to decode env RSA private key: %w", err) } block, _ := pem.Decode(rawPrivateKey) if block == nil { return "", fmt.Errorf("failed to parse PEM block containing the private key") } privKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) if err != nil { // Try generic PKCS8 if PKCS1 fails if key, err2 := x509.ParsePKCS8PrivateKey(block.Bytes); err2 == nil { if rsaKey, ok := key.(*rsa.PrivateKey); ok { privKey = rsaKey } else { return "", fmt.Errorf("key is not RSA") } } else { return "", err } } // 2. Decode ciphertext ciphertext, err := base64.StdEncoding.DecodeString(encryptedPayload) if err != nil { return "", err } // 3. Decrypt using RSA-OAEP plaintext, err := rsa.DecryptOAEP( sha256.New(), rand.Reader, privKey, ciphertext, nil, ) if err != nil { return "", err } return string(plaintext), nil } // ConfiguredService represents a service with saved credentials (without revealing the actual value) type ConfiguredService struct { ServiceName string `json:"service_name"` UpdatedAt string `json:"updated_at"` UpdatedBy string `json:"updated_by,omitempty"` IsConfigured bool `json:"is_configured"` } // ListConfiguredServices returns all configured services without revealing credential values func (s *CredentialsService) ListConfiguredServices(ctx context.Context) ([]ConfiguredService, error) { // Define all supported services - must match BootstrapCredentials keys allServices := []string{ "stripe", "storage", "cloudflare_config", "cpanel", "lavinmq", "appwrite", "fcm_service_account", "smtp", } query := ` SELECT service_name, updated_at, COALESCE(updated_by::text, '') as updated_by FROM external_services_credentials ` rows, err := s.DB.QueryContext(ctx, query) if err != nil { return nil, err } defer rows.Close() // Map of configured services configured := make(map[string]ConfiguredService) for rows.Next() { var cs ConfiguredService if err := rows.Scan(&cs.ServiceName, &cs.UpdatedAt, &cs.UpdatedBy); err != nil { return nil, err } cs.IsConfigured = true configured[cs.ServiceName] = cs } // Build result with all services result := make([]ConfiguredService, 0, len(allServices)) for _, name := range allServices { if cs, ok := configured[name]; ok { result = append(result, cs) } else { result = append(result, ConfiguredService{ ServiceName: name, IsConfigured: false, }) } } return result, nil } // DeleteCredentials removes credentials for a service func (s *CredentialsService) DeleteCredentials(ctx context.Context, serviceName string) error { query := `DELETE FROM external_services_credentials WHERE service_name = $1` _, err := s.DB.ExecContext(ctx, query, serviceName) if err != nil { return err } // Clear cache s.cacheMutex.Lock() delete(s.cache, serviceName) s.cacheMutex.Unlock() return nil } // EncryptPayload encrypts a payload using the derived public key func (s *CredentialsService) EncryptPayload(payload string) (string, error) { // 1. Decode Private Key from Env (to derive Public Key) // In a real scenario, you might store Public Key separately, but we can derive it. rawPrivateKey, err := base64.StdEncoding.DecodeString(getEnvRSAKey()) if err != nil { return "", fmt.Errorf("failed to decode env RSA private key: %w", err) } block, _ := pem.Decode(rawPrivateKey) if block == nil { return "", fmt.Errorf("failed to parse PEM block containing the private key") } privKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) if err != nil { // Try generic PKCS8 if PKCS1 fails if key, err2 := x509.ParsePKCS8PrivateKey(block.Bytes); err2 == nil { if rsaKey, ok := key.(*rsa.PrivateKey); ok { privKey = rsaKey } else { return "", fmt.Errorf("key is not RSA") } } else { return "", err } } pubKey := &privKey.PublicKey // 2. Encrypt using RSA-OAEP ciphertext, err := rsa.EncryptOAEP( sha256.New(), rand.Reader, pubKey, []byte(payload), nil, ) if err != nil { return "", fmt.Errorf("encryption failed: %w", err) } return base64.StdEncoding.EncodeToString(ciphertext), nil } // BootstrapCredentials checks if credentials are in DB, if not, migrates from Env func (s *CredentialsService) BootstrapCredentials(ctx context.Context) error { // List of services and their env mapping services := map[string]func() interface{}{ "stripe": func() interface{} { return map[string]string{ "secretKey": os.Getenv("STRIPE_SECRET_KEY"), "webhookSecret": os.Getenv("STRIPE_WEBHOOK_SECRET"), "publishableKey": os.Getenv("STRIPE_PUBLISHABLE_KEY"), } }, "storage": func() interface{} { return map[string]string{ "endpoint": os.Getenv("AWS_ENDPOINT"), "accessKey": os.Getenv("AWS_ACCESS_KEY_ID"), "secretKey": os.Getenv("AWS_SECRET_ACCESS_KEY"), "bucket": os.Getenv("S3_BUCKET"), "region": os.Getenv("AWS_REGION"), } }, "lavinmq": func() interface{} { return map[string]string{ "amqpUrl": os.Getenv("AMQP_URL"), } }, "cloudflare_config": func() interface{} { return map[string]string{ "apiToken": os.Getenv("CLOUDFLARE_API_TOKEN"), "zoneId": os.Getenv("CLOUDFLARE_ZONE_ID"), } }, "cpanel": func() interface{} { return map[string]string{ "host": os.Getenv("CPANEL_HOST"), "username": os.Getenv("CPANEL_USERNAME"), "apiToken": os.Getenv("CPANEL_API_TOKEN"), } }, "appwrite": func() interface{} { return map[string]string{ "endpoint": os.Getenv("APPWRITE_ENDPOINT"), "projectId": os.Getenv("APPWRITE_PROJECT_ID"), "apiKey": os.Getenv("APPWRITE_API_KEY"), } }, "fcm_service_account": func() interface{} { return map[string]string{ "serviceAccountJson": os.Getenv("FCM_SERVICE_ACCOUNT_JSON"), } }, "smtp": func() interface{} { return map[string]string{ "host": os.Getenv("SMTP_HOST"), "port": os.Getenv("SMTP_PORT"), "username": os.Getenv("SMTP_USERNAME"), "password": os.Getenv("SMTP_PASSWORD"), "from_email": os.Getenv("SMTP_FROM_EMAIL"), "from_name": os.Getenv("SMTP_FROM_NAME"), "secure": os.Getenv("SMTP_SECURE"), } }, } for service, getEnvData := range services { // Check if already configured configured, err := s.isServiceConfigured(ctx, service) if err != nil { fmt.Printf("[CredentialsBootstrap] Error checking %s: %v\n", service, err) continue } if !configured { data := getEnvData() // Validate if env vars exist (naive check: at least one field not empty) hasData := false jsonBytes, _ := json.Marshal(data) var stringMap map[string]string json.Unmarshal(jsonBytes, &stringMap) for _, v := range stringMap { if v != "" { hasData = true break } } if hasData { fmt.Printf("[CredentialsBootstrap] Migrating %s from Env to DB...\n", service) encrypted, err := s.EncryptPayload(string(jsonBytes)) if err != nil { fmt.Printf("[CredentialsBootstrap] Failed to encrypt %s: %v\n", service, err) continue } if err := s.SaveCredentials(ctx, service, encrypted, ""); err != nil { fmt.Printf("[CredentialsBootstrap] Failed to save %s: %v\n", service, err) } else { fmt.Printf("[CredentialsBootstrap] Successfully migrated %s\n", service) } } } } return nil } func (s *CredentialsService) isServiceConfigured(ctx context.Context, serviceName string) (bool, error) { var exists bool query := `SELECT EXISTS(SELECT 1 FROM external_services_credentials WHERE service_name = $1)` err := s.DB.QueryRowContext(ctx, query, serviceName).Scan(&exists) return exists, err } func getEnvRSAKey() string { key := os.Getenv("RSA_PRIVATE_KEY_BASE64") if key == "" { return "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2pBZ0VBQW9JQkFRQ200OGRqeTQyRzFuUWYKQ2dUSVlVdjlleFhnWTZld2JUZ2pPRG9sVnQ1MGxqSjV5YkdaSzNMcUF6eVpURmQvS0ZRL0hFTzBmZkhOOHJqMApOVkh3QXBzSXp2RUFpeHBMTTNiMUhYaFZnWW14NDhCWUJwZlE4dDN4cWdqTkUrWEFVUEdFR25zb1ducmJQV01EClJWQkFKVG1aOWZNSlc1OU1jdEdCU2t5eGlDODg1aUdnNFM2ZHdQdmNpUU0yRkhZcmlEWEtrNmJVMnhBQ2F0aUEKMEF3czgzSnpNS3dEKzJwckxMaDZ5VE9GdExrQ0tBdFloTS9GNHYxWE1xLzduODh5WC96Qk1zREhXRnRiOVZqdApWS1VNZnJ6MGFXRitHOE4rSVRmREVUTjBiTitrMTlqWDFOS2JRU1FKRE9FQ1FiMDFvb01MTi9YWldmOG9YRkZOCmgvY1Vpcmg5QWdNQkFBRUNnZ0VBRjlVcGNUL3RXeGNmQ0J1M0tTSno1cVFBTU1ZcWVWQnZsdC85dGIxZEVVc3QKdENTd2Z3NHNYK3pNWjV2MlZzdGNsSktsdkkzTHpUeGZXMGlPQmZkcFNtMjdEdmZPYm5UUUJSc0xSekt6aGpyZgpDZk1QRlNESnZ5d29DNWxPMHhMOEdvQnBuQTZud3MxV2FXNHBrcTkrTlZWaE9ySGg0dG4ydno3c0N1SkcxS3hSCmEzcFByMFQxUlFIUzA2RVUxR3dXZm1WcDRTT2RLdDVXRWhuK0YrL0FXWWo3QXlJMFljc3hSTmJDckkxYjYyWW0KSno1K3NZNmVvL3h5cUExS3hZNkl5YVJ4YXIrVlU0ek1QcVkvc1VOQlByQ0N5T28wMFlrQWJMZUp2V2tzVlJjZQp4N2hXUEhCRGk4YTg2QlkxYjlLL2xDT2lSK1JOdGpUaVVmU2VCWnl2UVFLQmdRRGQrL1k4M3RnOWczZzNzejI4CnRoUDVhZEE5TWljZ0hoUG5EdHhrZTh4MWxHOWV3TUdYNUVLUFErV05qclZTTjBKY3A5RjNaeWdodm1ZN1k5eisKc3d1YUdSc1ZuZUEwQ0Q0cFcySk5FVWtBQ0Y4WFdUdmF2ZmJlQnNCaDJOcVB5ckg0L2RSS0ozdHFDeHlNcHl6cwpWaks4ejFodWFpS3FnQ1I4SmRDeUNCSFFMUUtCZ1FEQWRvK3BhRmgzblBRK0cvY2dLQ2ZlR3VmSStXWkZ4SzJTCnAxOEMrTm1ENStIUFdoMWNYRTlQM1NSaE1LRlpDbHJuV3dvY21mNzlWWmJNUmlQWGN3TWk4aUcwcGZ0V2x3ZGQKTmVxZTVRQWNqUVYyWTk2aGdKTGxhTldVVm15MVJXcFg2T2dOajAyelVRN1BVRnNMaEh0T2RPWXFGeVpUNTVSYQp4OWdZSjlWcmtRS0JnQ0dNd0RXSTlLT3ZhQTc3RHh5alpEZHc3NkVnSUZ1eFVBNis1ejVrbTQwMXh2TktMTGc1Ckxub2FwK29TSklOdGlLRWFXQVUwMlJMb2hPajYrZDZnenVNV1lrcU5GdUttVUViTjRmaUY0VU9aQUU0MkZWN0YKRVRlVFM2WStNU0pFWDB1amlWOC93bDVQbVp3RWREeXY5bkVrNFZlbXdPQ0dCMzJmOVgvQ3luWnRBb0dBZlVTdAovUFdObjB6cExBUEh0WVp0YklMV21saTUyRzlMQ2trbDdpbEthakJqS1RMZUtWOXJ2KytQM2pKbzBpdUxQMHBpCktudVJIQks1TS92ekdDZ2p3bnNXdFIzVG1XaHp3cGQxUGphTy9BWk5wK0VZNXNWbzF5aUUyeWZsV1piMHdJTTMKaHB2ZlZ2ZExUR1JnM2Y0OHc3UVNteEsyUDZaYlNUc0p5NjhobWdFQ2dZRUFsemxCSEZ4RHBDYitKNmNpeUlhTApHU0pob3diODVKSTl5cXB0QW1FU0hNM3pnek8yRFhnSVZFYUFuWmN2N052dXQrNU54amszNVFOdmwwczFIL0RTCmRGcWVGVzJSdnpqZzVoQitqa3RjQjJ0N3pBZkdWNVc4UzlXRUVMMkJuYU5reGVTRGtyOVdIdXlEclpjRU90aG4KcEF3bEtDZWQzT21oUWYvcnB4NlZXcVk9Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K" } return key }