mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-06-04 13:30:32 +08:00
feat(security): add AES-256-GCM encryption for API keys at rest
- Add crypto utility (internal/utils/crypto.go) with AES-256-GCM encrypt/decrypt using SYSTEM_AES_KEY env var, with "enc:v1:" prefix for versioned ciphertext - Encrypt tenant API key via GORM BeforeSave/AfterFind hooks and manual encryption in CreateTenant/UpdateAPIKey (db.Updates bypasses hooks) - Encrypt model API key in ModelParameters Value/Scan (driver.Valuer) - Widen api_key column from varchar(64) to varchar(256) across all DB dialects (MySQL, ParadeDB, SQLite) and add versioned migration 000018 - Propagate SYSTEM_AES_KEY through docker-compose, Helm secrets and values - Fix migration 000017 PL/pgSQL dollar-quoting syntax ($ -> $$)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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: ""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
89
internal/utils/crypto.go
Normal file
89
internal/utils/crypto.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 $$;
|
||||
|
||||
@@ -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 $$;
|
||||
|
||||
@@ -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);
|
||||
2
migrations/versioned/000018_extend_tenant_api_key.up.sql
Normal file
2
migrations/versioned/000018_extend_tenant_api_key.up.sql
Normal file
@@ -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);
|
||||
Reference in New Issue
Block a user