Skip to content

Commit 6c69de2

Browse files
tsukiga-kireilyingbug
authored andcommitted
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 ($ -> $$)
1 parent 1d1d3de commit 6c69de2

File tree

16 files changed

+171
-11
lines changed

16 files changed

+171
-11
lines changed

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ AUTO_RECOVER_DIRTY=true
8585

8686
TENANT_AES_KEY=weknorarag-api-key-secret-secret
8787

88+
# AES-256 密钥,用于数据库中 API Key 等敏感字段的落盘加密(必须为32字节)
89+
SYSTEM_AES_KEY=weknora-system-aes-key-32bytes!!
90+
8891
# 是否开启知识图谱构建和检索(构建阶段需调用大模型,耗时较长)
8992
ENABLE_GRAPH_RAG=false
9093

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ services:
106106
- NEO4J_USERNAME=${NEO4J_USERNAME:-neo4j}
107107
- NEO4J_PASSWORD=${NEO4J_PASSWORD:-password}
108108
- TENANT_AES_KEY=${TENANT_AES_KEY:-}
109+
- SYSTEM_AES_KEY=${SYSTEM_AES_KEY:-}
109110
- CONCURRENCY_POOL_SIZE=${CONCURRENCY_POOL_SIZE:-5}
110111
- JWT_SECRET=${JWT_SECRET:-}
111112
# File size limit (in MB)

helm/templates/app.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ spec:
104104
secretKeyRef:
105105
name: {{ include "weknora.secretName" . }}
106106
key: TENANT_AES_KEY
107+
- name: SYSTEM_AES_KEY
108+
valueFrom:
109+
secretKeyRef:
110+
name: {{ include "weknora.secretName" . }}
111+
key: SYSTEM_AES_KEY
107112
# Retrieval & Storage
108113
- name: RETRIEVE_DRIVER
109114
value: {{ .Values.app.env.RETRIEVE_DRIVER | quote }}

helm/templates/secrets.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ stringData:
3030
# Application secrets
3131
JWT_SECRET: {{ required "secrets.jwtSecret is required" .Values.secrets.jwtSecret | quote }}
3232
TENANT_AES_KEY: {{ .Values.secrets.tenantAesKey | default (randAlphaNum 32) | quote }}
33+
SYSTEM_AES_KEY: {{ .Values.secrets.systemAesKey | default (randAlphaNum 32) | quote }}
3334
{{- if .Values.neo4j.enabled }}
3435
# Neo4j credentials (for GraphRAG)
3536
NEO4J_USERNAME: {{ .Values.neo4j.username | quote }}

helm/values.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,9 +394,11 @@ secrets:
394394
jwtSecret: ""
395395
# -- Tenant AES encryption key
396396
tenantAesKey: ""
397+
# -- System AES-256 key for database API key encryption (32 bytes)
398+
systemAesKey: ""
397399

398400
# -- Use existing secret instead of creating one
399-
# The secret must contain keys: DB_USER, DB_PASSWORD, DB_NAME, REDIS_USERNAME, REDIS_PASSWORD, JWT_SECRET, TENANT_AES_KEY
401+
# The secret must contain keys: DB_USER, DB_PASSWORD, DB_NAME, REDIS_USERNAME, REDIS_PASSWORD, JWT_SECRET, TENANT_AES_KEY, SYSTEM_AES_KEY
400402
existingSecret: ""
401403

402404
# -----------------------------------------------------------------------------

internal/application/service/tenant.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/Tencent/WeKnora/internal/logger"
1717
"github.com/Tencent/WeKnora/internal/types"
1818
"github.com/Tencent/WeKnora/internal/types/interfaces"
19+
"github.com/Tencent/WeKnora/internal/utils"
1920
)
2021

2122
var apiKeySecret = func() []byte {
@@ -67,6 +68,14 @@ func (s *tenantService) CreateTenant(ctx context.Context, tenant *types.Tenant)
6768

6869
logger.Infof(ctx, "Tenant created successfully, ID: %d, generating official API Key", tenant.ID)
6970
tenant.APIKey = s.generateApiKey(tenant.ID)
71+
72+
// Manually encrypt APIKey before update, because db.Updates() does not trigger BeforeSave hook
73+
if key := utils.GetAESKey(); key != nil && tenant.APIKey != "" {
74+
if encrypted, err := utils.EncryptAESGCM(tenant.APIKey, key); err == nil {
75+
tenant.APIKey = encrypted
76+
}
77+
}
78+
7079
if err := s.repo.UpdateTenant(ctx, tenant); err != nil {
7180
logger.ErrorWithFields(ctx, err, map[string]interface{}{
7281
"tenant_id": tenant.ID,
@@ -196,6 +205,13 @@ func (s *tenantService) UpdateAPIKey(ctx context.Context, id uint64) (string, er
196205
logger.Infof(ctx, "Generating new API Key for tenant, ID: %d", id)
197206
tenant.APIKey = s.generateApiKey(tenant.ID)
198207

208+
// Manually encrypt APIKey before update, because db.Updates() does not trigger BeforeSave hook
209+
if key := utils.GetAESKey(); key != nil && tenant.APIKey != "" {
210+
if encrypted, err := utils.EncryptAESGCM(tenant.APIKey, key); err == nil {
211+
tenant.APIKey = encrypted
212+
}
213+
}
214+
199215
if err := s.repo.UpdateTenant(ctx, tenant); err != nil {
200216
logger.ErrorWithFields(ctx, err, map[string]interface{}{
201217
"tenant_id": id,

internal/types/model.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"time"
77

8+
"github.com/Tencent/WeKnora/internal/utils"
89
"github.com/google/uuid"
910
"gorm.io/gorm"
1011
)
@@ -94,12 +95,19 @@ type Model struct {
9495
DeletedAt gorm.DeletedAt `yaml:"deleted_at" json:"deleted_at" gorm:"index"`
9596
}
9697

97-
// Value implements the driver.Valuer interface, used to convert ModelParameters to database value
98+
// Value implements the driver.Valuer interface, used to convert ModelParameters to database value.
99+
// Encrypts APIKey before persisting to database (value receiver = no memory pollution).
98100
func (c ModelParameters) Value() (driver.Value, error) {
101+
if key := utils.GetAESKey(); key != nil && c.APIKey != "" {
102+
if encrypted, err := utils.EncryptAESGCM(c.APIKey, key); err == nil {
103+
c.APIKey = encrypted
104+
}
105+
}
99106
return json.Marshal(c)
100107
}
101108

102-
// Scan implements the sql.Scanner interface, used to convert database value to ModelParameters
109+
// Scan implements the sql.Scanner interface, used to convert database value to ModelParameters.
110+
// Decrypts APIKey after loading from database; legacy plaintext is returned as-is.
103111
func (c *ModelParameters) Scan(value interface{}) error {
104112
if value == nil {
105113
return nil
@@ -108,7 +116,15 @@ func (c *ModelParameters) Scan(value interface{}) error {
108116
if !ok {
109117
return nil
110118
}
111-
return json.Unmarshal(b, c)
119+
if err := json.Unmarshal(b, c); err != nil {
120+
return err
121+
}
122+
if key := utils.GetAESKey(); key != nil && c.APIKey != "" {
123+
if decrypted, err := utils.DecryptAESGCM(c.APIKey, key); err == nil {
124+
c.APIKey = decrypted
125+
}
126+
}
127+
return nil
112128
}
113129

114130
// BeforeCreate is a GORM hook that runs before creating a new model record

internal/types/tenant.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"strings"
99
"time"
1010

11+
"github.com/Tencent/WeKnora/internal/utils"
1112
"gorm.io/gorm"
1213
)
1314

@@ -127,6 +128,28 @@ func (t *Tenant) BeforeCreate(tx *gorm.DB) error {
127128
return nil
128129
}
129130

131+
// BeforeSave encrypts APIKey before persisting to database.
132+
// Uses tx.Statement.SetColumn to avoid polluting the in-memory struct.
133+
func (t *Tenant) BeforeSave(tx *gorm.DB) error {
134+
if key := utils.GetAESKey(); key != nil && t.APIKey != "" {
135+
if encrypted, err := utils.EncryptAESGCM(t.APIKey, key); err == nil {
136+
tx.Statement.SetColumn("api_key", encrypted)
137+
}
138+
}
139+
return nil
140+
}
141+
142+
// AfterFind decrypts APIKey after loading from database.
143+
// Legacy plaintext (without enc:v1: prefix) is returned as-is.
144+
func (t *Tenant) AfterFind(tx *gorm.DB) error {
145+
if key := utils.GetAESKey(); key != nil && t.APIKey != "" {
146+
if decrypted, err := utils.DecryptAESGCM(t.APIKey, key); err == nil {
147+
t.APIKey = decrypted
148+
}
149+
}
150+
return nil
151+
}
152+
130153
// Value implements the driver.Valuer interface, used to convert RetrieverEngines to database value
131154
func (c RetrieverEngines) Value() (driver.Value, error) {
132155
return json.Marshal(c)

internal/utils/crypto.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package utils
2+
3+
import (
4+
"crypto/aes"
5+
"crypto/cipher"
6+
"crypto/rand"
7+
"encoding/base64"
8+
"errors"
9+
"io"
10+
"os"
11+
"strings"
12+
)
13+
14+
// EncPrefix marks a string as AES-256-GCM encrypted
15+
const EncPrefix = "enc:v1:"
16+
17+
// GetAESKey reads the 32-byte AES key from SYSTEM_AES_KEY env.
18+
// Returns nil if not set or not exactly 32 bytes.
19+
func GetAESKey() []byte {
20+
key := []byte(os.Getenv("SYSTEM_AES_KEY"))
21+
if len(key) == 32 {
22+
return key
23+
}
24+
return nil
25+
}
26+
27+
// EncryptAESGCM encrypts plaintext with AES-256-GCM.
28+
// Returns the original string if empty, already encrypted, or key is nil.
29+
func EncryptAESGCM(plaintext string, key []byte) (string, error) {
30+
if plaintext == "" || key == nil {
31+
return plaintext, nil
32+
}
33+
if strings.HasPrefix(plaintext, EncPrefix) {
34+
return plaintext, nil
35+
}
36+
37+
block, err := aes.NewCipher(key)
38+
if err != nil {
39+
return "", err
40+
}
41+
aesgcm, err := cipher.NewGCM(block)
42+
if err != nil {
43+
return "", err
44+
}
45+
46+
nonce := make([]byte, aesgcm.NonceSize())
47+
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
48+
return "", err
49+
}
50+
51+
ciphertext := aesgcm.Seal(nil, nonce, []byte(plaintext), nil)
52+
combined := append(nonce, ciphertext...)
53+
return EncPrefix + base64.RawURLEncoding.EncodeToString(combined), nil
54+
}
55+
56+
// DecryptAESGCM decrypts an AES-256-GCM encrypted string.
57+
// If the string lacks the enc:v1: prefix, it's treated as legacy plaintext and returned as-is.
58+
func DecryptAESGCM(encrypted string, key []byte) (string, error) {
59+
if encrypted == "" || key == nil {
60+
return encrypted, nil
61+
}
62+
if !strings.HasPrefix(encrypted, EncPrefix) {
63+
return encrypted, nil
64+
}
65+
66+
data, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(encrypted, EncPrefix))
67+
if err != nil {
68+
return "", err
69+
}
70+
if len(data) < 12 {
71+
return "", errors.New("invalid encrypted data: too short")
72+
}
73+
74+
block, err := aes.NewCipher(key)
75+
if err != nil {
76+
return "", err
77+
}
78+
aesgcm, err := cipher.NewGCM(block)
79+
if err != nil {
80+
return "", err
81+
}
82+
83+
nonce, ciphertext := data[:aesgcm.NonceSize()], data[aesgcm.NonceSize():]
84+
plaintext, err := aesgcm.Open(nil, nonce, ciphertext, nil)
85+
if err != nil {
86+
return "", err
87+
}
88+
return string(plaintext), nil
89+
}

migrations/mysql/00-init-db.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ CREATE TABLE tenants (
1010
id BIGINT AUTO_INCREMENT PRIMARY KEY,
1111
name VARCHAR(255) NOT NULL,
1212
description TEXT,
13-
api_key VARCHAR(64) NOT NULL,
13+
api_key VARCHAR(256) NOT NULL,
1414
retriever_engines JSON NOT NULL,
1515
status VARCHAR(50) DEFAULT 'active',
1616
business VARCHAR(255) NOT NULL,

0 commit comments

Comments
 (0)