Skip to content

Commit 0ea2d20

Browse files
authored
feat: Encryption Configuration Error Detection (#251)
* feat: Decrypt error alert * feat: Check proxy key * feat: ENCRYPTION_KEY tip * feat: Update readme for jp
1 parent 480f267 commit 0ea2d20

File tree

9 files changed

+1011
-44
lines changed

9 files changed

+1011
-44
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# GPT-Load
22

3-
English | [中文文档](README_CN.md)
3+
English | [中文文档](README_CN.md) | [日本語](README_JP.md)
44

55
[![Release](https://img.shields.io/github/v/release/tbphp/gpt-load)](https://github.com/tbphp/gpt-load/releases)
66
![Go Version](https://img.shields.io/badge/Go-1.23+-blue.svg)
@@ -341,6 +341,7 @@ make run
341341
### Important Notes
342342

343343
⚠️ **Important Reminders**:
344+
- **Once ENCRYPTION_KEY is lost, encrypted data CANNOT be recovered!** Please securely backup this key. Consider using a password manager or secure key management system
344345
- **Service must be stopped** before migration to avoid data inconsistency
345346
- Strongly recommended to **backup the database** in case migration fails and recovery is needed
346347
- Keys should use **32 characters or longer random strings** for security

README_CN.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# GPT-Load
22

3-
[English](README.md) | 中文文档
3+
[English](README.md) | 中文文档 | [日本語](README_JP.md)
44

55
[![Release](https://img.shields.io/github/v/release/tbphp/gpt-load)](https://github.com/tbphp/gpt-load/releases)
66
![Go Version](https://img.shields.io/badge/Go-1.23+-blue.svg)
@@ -341,6 +341,7 @@ make run
341341
### 注意事项
342342

343343
⚠️ **重要提醒**
344+
- **ENCRYPTION_KEY 一旦丢失将无法恢复已加密的数据!** 请务必安全备份此密钥,建议使用密码管理器或安全的密钥管理系统保存
344345
- 迁移前**必须停止服务**,避免数据不一致
345346
- 强烈建议**备份数据库**,以防迁移失败需要恢复
346347
- 密钥建议使用 **32 位或更长的随机字符串**,确保安全性

README_JP.md

Lines changed: 573 additions & 0 deletions
Large diffs are not rendered by default.

internal/handler/dashboard_handler.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ package handler
33
import (
44
"fmt"
55
"strings"
6+
"gpt-load/internal/encryption"
67
app_errors "gpt-load/internal/errors"
78
"gpt-load/internal/models"
89
"gpt-load/internal/response"
910
"time"
1011

1112
"github.com/gin-gonic/gin"
13+
"github.com/sirupsen/logrus"
1214
)
1315

1416
// Stats Get dashboard statistics
@@ -261,6 +263,38 @@ func (s *Server) getSecurityWarnings() []models.SecurityWarning {
261263
warnings = append(warnings, encryptionWarnings...)
262264
}
263265

266+
// 检查系统级代理密钥
267+
systemSettings := s.SettingsManager.GetSettings()
268+
if systemSettings.ProxyKeys != "" {
269+
proxyKeys := strings.Split(systemSettings.ProxyKeys, ",")
270+
for i, key := range proxyKeys {
271+
key = strings.TrimSpace(key)
272+
if key != "" {
273+
keyName := fmt.Sprintf("全局代理密钥 #%d", i+1)
274+
proxyWarnings := checkPasswordSecurity(key, keyName)
275+
warnings = append(warnings, proxyWarnings...)
276+
}
277+
}
278+
}
279+
280+
// 检查分组级代理密钥
281+
var groups []models.Group
282+
if err := s.DB.Where("proxy_keys IS NOT NULL AND proxy_keys != ''").Find(&groups).Error; err == nil {
283+
for _, group := range groups {
284+
if group.ProxyKeys != "" {
285+
proxyKeys := strings.Split(group.ProxyKeys, ",")
286+
for i, key := range proxyKeys {
287+
key = strings.TrimSpace(key)
288+
if key != "" {
289+
keyName := fmt.Sprintf("分组 [%s] 的代理密钥 #%d", group.Name, i+1)
290+
proxyWarnings := checkPasswordSecurity(key, keyName)
291+
warnings = append(warnings, proxyWarnings...)
292+
}
293+
}
294+
}
295+
}
296+
}
297+
264298
return warnings
265299
}
266300

@@ -344,3 +378,92 @@ func hasGoodComplexity(password string) bool {
344378

345379
return count >= 3
346380
}
381+
382+
// EncryptionStatus checks if ENCRYPTION_KEY is configured but keys are not encrypted
383+
func (s *Server) EncryptionStatus(c *gin.Context) {
384+
hasMismatch, message, suggestion := s.checkEncryptionMismatch()
385+
386+
response.Success(c, gin.H{
387+
"has_mismatch": hasMismatch,
388+
"message": message,
389+
"suggestion": suggestion,
390+
})
391+
}
392+
393+
// checkEncryptionMismatch detects encryption configuration mismatches
394+
func (s *Server) checkEncryptionMismatch() (bool, string, string) {
395+
encryptionKey := s.config.GetEncryptionKey()
396+
397+
// Sample check API keys
398+
var sampleKeys []models.APIKey
399+
if err := s.DB.Limit(20).Where("key_hash IS NOT NULL AND key_hash != ''").Find(&sampleKeys).Error; err != nil {
400+
logrus.WithError(err).Error("Failed to fetch sample keys for encryption check")
401+
return false, "", ""
402+
}
403+
404+
if len(sampleKeys) == 0 {
405+
// No keys in database, no mismatch
406+
return false, "", ""
407+
}
408+
409+
// Check hash consistency with unencrypted data
410+
noopService, err := encryption.NewService("")
411+
if err != nil {
412+
logrus.WithError(err).Error("Failed to create noop encryption service")
413+
return false, "", ""
414+
}
415+
416+
unencryptedHashMatchCount := 0
417+
for _, key := range sampleKeys {
418+
// For unencrypted data: key_hash should match SHA256(key_value)
419+
expectedHash := noopService.Hash(key.KeyValue)
420+
if expectedHash == key.KeyHash {
421+
unencryptedHashMatchCount++
422+
}
423+
}
424+
425+
unencryptedConsistencyRate := float64(unencryptedHashMatchCount) / float64(len(sampleKeys))
426+
427+
// If ENCRYPTION_KEY is configured, also check if current key can decrypt the data
428+
var currentKeyHashMatchCount int
429+
if encryptionKey != "" {
430+
currentService, err := encryption.NewService(encryptionKey)
431+
if err == nil {
432+
for _, key := range sampleKeys {
433+
// Try to decrypt and re-hash to check if current key matches
434+
decrypted, err := currentService.Decrypt(key.KeyValue)
435+
if err == nil {
436+
// Successfully decrypted, check if hash matches
437+
expectedHash := currentService.Hash(decrypted)
438+
if expectedHash == key.KeyHash {
439+
currentKeyHashMatchCount++
440+
}
441+
}
442+
}
443+
}
444+
}
445+
currentKeyConsistencyRate := float64(currentKeyHashMatchCount) / float64(len(sampleKeys))
446+
447+
// Scenario A: ENCRYPTION_KEY configured but data not encrypted
448+
if encryptionKey != "" && unencryptedConsistencyRate > 0.8 {
449+
return true,
450+
"检测到您已配置 ENCRYPTION_KEY,但数据库中的密钥尚未加密。这会导致密钥无法正常读取(显示为 failed-to-decrypt)。",
451+
"请停止服务,执行密钥迁移命令后重启"
452+
}
453+
454+
// Scenario B: ENCRYPTION_KEY not configured but data is encrypted
455+
if encryptionKey == "" && unencryptedConsistencyRate < 0.2 {
456+
return true,
457+
"检测到数据库中的密钥已加密,但未配置 ENCRYPTION_KEY。这会导致密钥无法正常读取。",
458+
"请配置与加密时相同的 ENCRYPTION_KEY,或执行解密迁移"
459+
}
460+
461+
// Scenario C: ENCRYPTION_KEY configured but doesn't match encrypted data
462+
if encryptionKey != "" && unencryptedConsistencyRate < 0.2 && currentKeyConsistencyRate < 0.2 {
463+
return true,
464+
"检测到您配置的 ENCRYPTION_KEY 与数据加密时使用的密钥不匹配。这会导致密钥解密失败(显示为 failed-to-decrypt)。",
465+
"请使用正确的 ENCRYPTION_KEY,或执行密钥迁移"
466+
}
467+
468+
return false, "", ""
469+
}

internal/router/router.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ func registerProtectedAPIRoutes(api *gin.RouterGroup, serverHandler *handler.Ser
140140
{
141141
dashboard.GET("/stats", serverHandler.Stats)
142142
dashboard.GET("/chart", serverHandler.Chart)
143+
dashboard.GET("/encryption-status", serverHandler.EncryptionStatus)
143144
}
144145

145146
// 日志

web/src/components/BaseInfoCard.vue

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
<script setup lang="ts">
2-
import { getDashboardStats } from "@/api/dashboard";
32
import type { DashboardStatsResponse } from "@/types/models";
43
import { NCard, NGrid, NGridItem, NSpace, NTag, NTooltip } from "naive-ui";
5-
import { onMounted, ref } from "vue";
4+
import { computed, onMounted, ref } from "vue";
65
7-
// 统计数据
8-
const stats = ref<DashboardStatsResponse | null>(null);
9-
const loading = ref(true);
6+
// Props
7+
interface Props {
8+
stats: DashboardStatsResponse | null;
9+
loading?: boolean;
10+
}
11+
12+
const props = withDefaults(defineProps<Props>(), {
13+
loading: false,
14+
});
15+
16+
// 使用计算属性代替ref
17+
const stats = computed(() => props.stats);
1018
const animatedValues = ref<Record<string, number>>({});
1119
1220
// 格式化数值显示
@@ -26,14 +34,9 @@ const formatTrend = (trend: number): string => {
2634
return `${sign}${trend.toFixed(1)}%`;
2735
};
2836
29-
// 获取统计数据
30-
const fetchStats = async () => {
31-
try {
32-
loading.value = true;
33-
const response = await getDashboardStats();
34-
stats.value = response.data;
35-
36-
// 添加动画效果
37+
// 监听stats变化并更新动画值
38+
const updateAnimatedValues = () => {
39+
if (stats.value) {
3740
setTimeout(() => {
3841
animatedValues.value = {
3942
key_count:
@@ -44,15 +47,12 @@ const fetchStats = async () => {
4447
error_rate: (100 - (stats.value?.error_rate?.value ?? 0)) / 100,
4548
};
4649
}, 0);
47-
} catch (error) {
48-
console.error("获取统计数据失败:", error);
49-
} finally {
50-
loading.value = false;
5150
}
5251
};
5352
53+
// 监听stats变化
5454
onMounted(() => {
55-
fetchStats();
55+
updateAnimatedValues();
5656
});
5757
</script>
5858

0 commit comments

Comments
 (0)