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:
AndyYang
2026-03-06 22:45:24 +08:00
committed by lyingbug
parent 1d1d3de76a
commit 6c69de2df1
16 changed files with 171 additions and 11 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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: ""
# -----------------------------------------------------------------------------

View File

@@ -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,

View File

@@ -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

View File

@@ -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
View 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
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 $$;

View File

@@ -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 $$;

View File

@@ -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);

View 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);