diff --git a/.env.example b/.env.example index df19d5d1..39a0ae64 100644 --- a/.env.example +++ b/.env.example @@ -85,6 +85,9 @@ AUTO_RECOVER_DIRTY=true TENANT_AES_KEY=weknorarag-api-key-secret-secret +# AES-256 密钥,用于数据库中 API Key 等敏感字段的落盘加密(必须为32字节) +SYSTEM_AES_KEY=weknora-system-aes-key-32bytes!! + # 是否开启知识图谱构建和检索(构建阶段需调用大模型,耗时较长) ENABLE_GRAPH_RAG=false diff --git a/docker-compose.yml b/docker-compose.yml index 46462e9f..1b1f7d22 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -106,6 +106,7 @@ services: - NEO4J_USERNAME=${NEO4J_USERNAME:-neo4j} - NEO4J_PASSWORD=${NEO4J_PASSWORD:-password} - TENANT_AES_KEY=${TENANT_AES_KEY:-} + - SYSTEM_AES_KEY=${SYSTEM_AES_KEY:-} - CONCURRENCY_POOL_SIZE=${CONCURRENCY_POOL_SIZE:-5} - JWT_SECRET=${JWT_SECRET:-} # File size limit (in MB) diff --git a/helm/templates/app.yaml b/helm/templates/app.yaml index bca2d448..b475fcef 100644 --- a/helm/templates/app.yaml +++ b/helm/templates/app.yaml @@ -104,6 +104,11 @@ spec: secretKeyRef: name: {{ include "weknora.secretName" . }} key: TENANT_AES_KEY + - name: SYSTEM_AES_KEY + valueFrom: + secretKeyRef: + name: {{ include "weknora.secretName" . }} + key: SYSTEM_AES_KEY # Retrieval & Storage - name: RETRIEVE_DRIVER value: {{ .Values.app.env.RETRIEVE_DRIVER | quote }} diff --git a/helm/templates/secrets.yaml b/helm/templates/secrets.yaml index f41dc2ab..531d729f 100644 --- a/helm/templates/secrets.yaml +++ b/helm/templates/secrets.yaml @@ -30,6 +30,7 @@ stringData: # Application secrets JWT_SECRET: {{ required "secrets.jwtSecret is required" .Values.secrets.jwtSecret | quote }} TENANT_AES_KEY: {{ .Values.secrets.tenantAesKey | default (randAlphaNum 32) | quote }} + SYSTEM_AES_KEY: {{ .Values.secrets.systemAesKey | default (randAlphaNum 32) | quote }} {{- if .Values.neo4j.enabled }} # Neo4j credentials (for GraphRAG) NEO4J_USERNAME: {{ .Values.neo4j.username | quote }} diff --git a/helm/values.yaml b/helm/values.yaml index f23df9a6..adfc7aec 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -394,9 +394,11 @@ secrets: jwtSecret: "" # -- Tenant AES encryption key tenantAesKey: "" + # -- System AES-256 key for database API key encryption (32 bytes) + systemAesKey: "" # -- Use existing secret instead of creating one - # The secret must contain keys: DB_USER, DB_PASSWORD, DB_NAME, REDIS_USERNAME, REDIS_PASSWORD, JWT_SECRET, TENANT_AES_KEY + # The secret must contain keys: DB_USER, DB_PASSWORD, DB_NAME, REDIS_USERNAME, REDIS_PASSWORD, JWT_SECRET, TENANT_AES_KEY, SYSTEM_AES_KEY existingSecret: "" # ----------------------------------------------------------------------------- diff --git a/internal/application/service/tenant.go b/internal/application/service/tenant.go index 47f98520..f3ceeafe 100644 --- a/internal/application/service/tenant.go +++ b/internal/application/service/tenant.go @@ -16,6 +16,7 @@ import ( "github.com/Tencent/WeKnora/internal/logger" "github.com/Tencent/WeKnora/internal/types" "github.com/Tencent/WeKnora/internal/types/interfaces" + "github.com/Tencent/WeKnora/internal/utils" ) var apiKeySecret = func() []byte { @@ -67,6 +68,14 @@ func (s *tenantService) CreateTenant(ctx context.Context, tenant *types.Tenant) logger.Infof(ctx, "Tenant created successfully, ID: %d, generating official API Key", tenant.ID) tenant.APIKey = s.generateApiKey(tenant.ID) + + // Manually encrypt APIKey before update, because db.Updates() does not trigger BeforeSave hook + if key := utils.GetAESKey(); key != nil && tenant.APIKey != "" { + if encrypted, err := utils.EncryptAESGCM(tenant.APIKey, key); err == nil { + tenant.APIKey = encrypted + } + } + if err := s.repo.UpdateTenant(ctx, tenant); err != nil { logger.ErrorWithFields(ctx, err, map[string]interface{}{ "tenant_id": tenant.ID, @@ -196,6 +205,13 @@ func (s *tenantService) UpdateAPIKey(ctx context.Context, id uint64) (string, er logger.Infof(ctx, "Generating new API Key for tenant, ID: %d", id) tenant.APIKey = s.generateApiKey(tenant.ID) + // Manually encrypt APIKey before update, because db.Updates() does not trigger BeforeSave hook + if key := utils.GetAESKey(); key != nil && tenant.APIKey != "" { + if encrypted, err := utils.EncryptAESGCM(tenant.APIKey, key); err == nil { + tenant.APIKey = encrypted + } + } + if err := s.repo.UpdateTenant(ctx, tenant); err != nil { logger.ErrorWithFields(ctx, err, map[string]interface{}{ "tenant_id": id, diff --git a/internal/types/model.go b/internal/types/model.go index 8cfb60ae..bba8f04f 100644 --- a/internal/types/model.go +++ b/internal/types/model.go @@ -5,6 +5,7 @@ import ( "encoding/json" "time" + "github.com/Tencent/WeKnora/internal/utils" "github.com/google/uuid" "gorm.io/gorm" ) @@ -94,12 +95,19 @@ type Model struct { DeletedAt gorm.DeletedAt `yaml:"deleted_at" json:"deleted_at" gorm:"index"` } -// Value implements the driver.Valuer interface, used to convert ModelParameters to database value +// Value implements the driver.Valuer interface, used to convert ModelParameters to database value. +// Encrypts APIKey before persisting to database (value receiver = no memory pollution). func (c ModelParameters) Value() (driver.Value, error) { + if key := utils.GetAESKey(); key != nil && c.APIKey != "" { + if encrypted, err := utils.EncryptAESGCM(c.APIKey, key); err == nil { + c.APIKey = encrypted + } + } return json.Marshal(c) } -// Scan implements the sql.Scanner interface, used to convert database value to ModelParameters +// Scan implements the sql.Scanner interface, used to convert database value to ModelParameters. +// Decrypts APIKey after loading from database; legacy plaintext is returned as-is. func (c *ModelParameters) Scan(value interface{}) error { if value == nil { return nil @@ -108,7 +116,15 @@ func (c *ModelParameters) Scan(value interface{}) error { if !ok { return nil } - return json.Unmarshal(b, c) + if err := json.Unmarshal(b, c); err != nil { + return err + } + if key := utils.GetAESKey(); key != nil && c.APIKey != "" { + if decrypted, err := utils.DecryptAESGCM(c.APIKey, key); err == nil { + c.APIKey = decrypted + } + } + return nil } // BeforeCreate is a GORM hook that runs before creating a new model record diff --git a/internal/types/tenant.go b/internal/types/tenant.go index 75e5812c..a3c77b48 100644 --- a/internal/types/tenant.go +++ b/internal/types/tenant.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/Tencent/WeKnora/internal/utils" "gorm.io/gorm" ) @@ -127,6 +128,28 @@ func (t *Tenant) BeforeCreate(tx *gorm.DB) error { return nil } +// BeforeSave encrypts APIKey before persisting to database. +// Uses tx.Statement.SetColumn to avoid polluting the in-memory struct. +func (t *Tenant) BeforeSave(tx *gorm.DB) error { + if key := utils.GetAESKey(); key != nil && t.APIKey != "" { + if encrypted, err := utils.EncryptAESGCM(t.APIKey, key); err == nil { + tx.Statement.SetColumn("api_key", encrypted) + } + } + return nil +} + +// AfterFind decrypts APIKey after loading from database. +// Legacy plaintext (without enc:v1: prefix) is returned as-is. +func (t *Tenant) AfterFind(tx *gorm.DB) error { + if key := utils.GetAESKey(); key != nil && t.APIKey != "" { + if decrypted, err := utils.DecryptAESGCM(t.APIKey, key); err == nil { + t.APIKey = decrypted + } + } + return nil +} + // Value implements the driver.Valuer interface, used to convert RetrieverEngines to database value func (c RetrieverEngines) Value() (driver.Value, error) { return json.Marshal(c) diff --git a/internal/utils/crypto.go b/internal/utils/crypto.go new file mode 100644 index 00000000..75991099 --- /dev/null +++ b/internal/utils/crypto.go @@ -0,0 +1,89 @@ +package utils + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "io" + "os" + "strings" +) + +// EncPrefix marks a string as AES-256-GCM encrypted +const EncPrefix = "enc:v1:" + +// GetAESKey reads the 32-byte AES key from SYSTEM_AES_KEY env. +// Returns nil if not set or not exactly 32 bytes. +func GetAESKey() []byte { + key := []byte(os.Getenv("SYSTEM_AES_KEY")) + if len(key) == 32 { + return key + } + return nil +} + +// EncryptAESGCM encrypts plaintext with AES-256-GCM. +// Returns the original string if empty, already encrypted, or key is nil. +func EncryptAESGCM(plaintext string, key []byte) (string, error) { + if plaintext == "" || key == nil { + return plaintext, nil + } + if strings.HasPrefix(plaintext, EncPrefix) { + return plaintext, nil + } + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, aesgcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + ciphertext := aesgcm.Seal(nil, nonce, []byte(plaintext), nil) + combined := append(nonce, ciphertext...) + return EncPrefix + base64.RawURLEncoding.EncodeToString(combined), nil +} + +// DecryptAESGCM decrypts an AES-256-GCM encrypted string. +// If the string lacks the enc:v1: prefix, it's treated as legacy plaintext and returned as-is. +func DecryptAESGCM(encrypted string, key []byte) (string, error) { + if encrypted == "" || key == nil { + return encrypted, nil + } + if !strings.HasPrefix(encrypted, EncPrefix) { + return encrypted, nil + } + + data, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(encrypted, EncPrefix)) + if err != nil { + return "", err + } + if len(data) < 12 { + return "", errors.New("invalid encrypted data: too short") + } + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce, ciphertext := data[:aesgcm.NonceSize()], data[aesgcm.NonceSize():] + plaintext, err := aesgcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + return string(plaintext), nil +} diff --git a/migrations/mysql/00-init-db.sql b/migrations/mysql/00-init-db.sql index fc00c643..c7c843d8 100644 --- a/migrations/mysql/00-init-db.sql +++ b/migrations/mysql/00-init-db.sql @@ -10,7 +10,7 @@ CREATE TABLE tenants ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, description TEXT, - api_key VARCHAR(64) NOT NULL, + api_key VARCHAR(256) NOT NULL, retriever_engines JSON NOT NULL, status VARCHAR(50) DEFAULT 'active', business VARCHAR(255) NOT NULL, diff --git a/migrations/paradedb/00-init-db.sql b/migrations/paradedb/00-init-db.sql index 29daff6b..2d4771ad 100644 --- a/migrations/paradedb/00-init-db.sql +++ b/migrations/paradedb/00-init-db.sql @@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS tenants ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, description TEXT, - api_key VARCHAR(64) NOT NULL, + api_key VARCHAR(256) NOT NULL, retriever_engines JSONB NOT NULL DEFAULT '[]', status VARCHAR(50) DEFAULT 'active', business VARCHAR(255) NOT NULL, diff --git a/migrations/sqlite/000000_init.up.sql b/migrations/sqlite/000000_init.up.sql index 19b22973..6b6f3312 100644 --- a/migrations/sqlite/000000_init.up.sql +++ b/migrations/sqlite/000000_init.up.sql @@ -4,7 +4,7 @@ CREATE TABLE IF NOT EXISTS tenants ( id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255) NOT NULL, description TEXT, - api_key VARCHAR(64) NOT NULL, + api_key VARCHAR(256) NOT NULL, retriever_engines TEXT NOT NULL DEFAULT '[]', status VARCHAR(50) DEFAULT 'active', business VARCHAR(255) NOT NULL, diff --git a/migrations/versioned/000017_mcp_builtin.down.sql b/migrations/versioned/000017_mcp_builtin.down.sql index 7024007d..70791330 100644 --- a/migrations/versioned/000017_mcp_builtin.down.sql +++ b/migrations/versioned/000017_mcp_builtin.down.sql @@ -2,7 +2,7 @@ -- Migration 000017 DOWN: Remove is_builtin from mcp_services -- ============================================================================ -DO $ BEGIN RAISE NOTICE '[Migration 000017 DOWN] Removing is_builtin column from mcp_services...'; END $; +DO $$ BEGIN RAISE NOTICE '[Migration 000017 DOWN] Removing is_builtin column from mcp_services...'; END $$; -- Drop index DROP INDEX IF EXISTS idx_mcp_services_is_builtin; @@ -11,4 +11,4 @@ DROP INDEX IF EXISTS idx_mcp_services_is_builtin; ALTER TABLE mcp_services DROP COLUMN IF EXISTS is_builtin; -DO $ BEGIN RAISE NOTICE '[Migration 000017 DOWN] is_builtin column removed from mcp_services'; END $; +DO $$ BEGIN RAISE NOTICE '[Migration 000017 DOWN] is_builtin column removed from mcp_services'; END $$; diff --git a/migrations/versioned/000017_mcp_builtin.up.sql b/migrations/versioned/000017_mcp_builtin.up.sql index e1ea0d56..73cbe588 100644 --- a/migrations/versioned/000017_mcp_builtin.up.sql +++ b/migrations/versioned/000017_mcp_builtin.up.sql @@ -2,10 +2,10 @@ -- Migration 000017: Add is_builtin support for MCP services -- ============================================================================ -DO $ BEGIN RAISE NOTICE '[Migration 000017] Adding is_builtin column to mcp_services...'; END $; +DO $$ BEGIN RAISE NOTICE '[Migration 000017] Adding is_builtin column to mcp_services...'; END $$; -- Add is_builtin column to mcp_services ALTER TABLE mcp_services ADD COLUMN IF NOT EXISTS is_builtin BOOLEAN NOT NULL DEFAULT false; CREATE INDEX IF NOT EXISTS idx_mcp_services_is_builtin ON mcp_services(is_builtin); -DO $ BEGIN RAISE NOTICE '[Migration 000017] is_builtin column added to mcp_services'; END $; +DO $$ BEGIN RAISE NOTICE '[Migration 000017] is_builtin column added to mcp_services'; END $$; diff --git a/migrations/versioned/000018_extend_tenant_api_key.down.sql b/migrations/versioned/000018_extend_tenant_api_key.down.sql new file mode 100644 index 00000000..87f853a8 --- /dev/null +++ b/migrations/versioned/000018_extend_tenant_api_key.down.sql @@ -0,0 +1,2 @@ +-- Migration 000018 DOWN: Revert tenant api_key column to varchar(64) +ALTER TABLE tenants ALTER COLUMN api_key TYPE varchar(64); diff --git a/migrations/versioned/000018_extend_tenant_api_key.up.sql b/migrations/versioned/000018_extend_tenant_api_key.up.sql new file mode 100644 index 00000000..04849478 --- /dev/null +++ b/migrations/versioned/000018_extend_tenant_api_key.up.sql @@ -0,0 +1,2 @@ +-- Migration 000018: Extend tenant api_key column to support encrypted values +ALTER TABLE tenants ALTER COLUMN api_key TYPE varchar(256);