394 lines
11 KiB
Go
394 lines
11 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"strings"
|
|
"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, $3, 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. Load Private Key bytes from env with fallbacks (base64, raw PEM, \n literals)
|
|
rawPrivateKey, err := getRawPrivateKeyBytes()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to obtain 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
|
|
allServices := []string{
|
|
"appwrite",
|
|
"stripe",
|
|
"firebase",
|
|
"cloudflare",
|
|
"smtp",
|
|
"s3",
|
|
"lavinmq",
|
|
}
|
|
|
|
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. Load Private Key bytes from env with fallbacks (base64, raw PEM, \n literals)
|
|
rawPrivateKey, err := getRawPrivateKeyBytes()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to obtain 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
|
|
}
|
|
|
|
// getRawPrivateKeyBytes attempts to load the RSA private key from the environment
|
|
// trying several fallbacks:
|
|
// 1) Treat env as base64 and decode
|
|
// 2) Treat env as a PEM string with literal "\n" escapes and replace them
|
|
// 3) Treat env as raw PEM
|
|
// 4) Trim and try base64 again
|
|
func getRawPrivateKeyBytes() ([]byte, error) {
|
|
env := os.Getenv("RSA_PRIVATE_KEY_BASE64")
|
|
if env == "" {
|
|
return nil, fmt.Errorf("RSA_PRIVATE_KEY_BASE64 environment variable is empty")
|
|
}
|
|
|
|
// Try base64 decode first
|
|
if b, err := base64.StdEncoding.DecodeString(env); err == nil {
|
|
if block, _ := pem.Decode(b); block != nil {
|
|
return b, nil
|
|
}
|
|
// Return decoded bytes even if pem.Decode returned nil; parsing later will catch it
|
|
return b, nil
|
|
}
|
|
|
|
// Try replacing literal \n with real newlines
|
|
envNew := strings.ReplaceAll(env, "\\n", "\n")
|
|
if block, _ := pem.Decode([]byte(envNew)); block != nil {
|
|
return []byte(envNew), nil
|
|
}
|
|
|
|
// Try raw env as PEM
|
|
if block, _ := pem.Decode([]byte(env)); block != nil {
|
|
return []byte(env), nil
|
|
}
|
|
|
|
// Trim and try base64 again
|
|
trimmed := strings.TrimSpace(env)
|
|
if b, err := base64.StdEncoding.DecodeString(trimmed); err == nil {
|
|
return b, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("could not decode RSA private key from env (tried base64 and PEM variants)")
|
|
}
|
|
|
|
// 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"),
|
|
}
|
|
},
|
|
"payment_gateway": func() interface{} {
|
|
return map[string]string{
|
|
"merchantId": os.Getenv("PAYMENT_GATEWAY_MERCHANT_ID"),
|
|
"apiKey": os.Getenv("PAYMENT_GATEWAY_API_KEY"),
|
|
"endpoint": os.Getenv("PAYMENT_GATEWAY_ENDPOINT"),
|
|
"webhookSecret": os.Getenv("PAYMENT_GATEWAY_WEBHOOK_SECRET"),
|
|
}
|
|
},
|
|
"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"),
|
|
}
|
|
},
|
|
}
|
|
|
|
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, "system_bootstrap"); 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
|
|
}
|