fix: save llm key without extra secret
This commit is contained in:
@@ -201,7 +201,7 @@ function sourceLabel(source: ApiKeyState['source']) {
|
|||||||
<a-button danger :loading="deletingKey">删除</a-button>
|
<a-button danger :loading="deletingKey">删除</a-button>
|
||||||
</a-popconfirm>
|
</a-popconfirm>
|
||||||
</div>
|
</div>
|
||||||
<p class="key-note">服务端需配置 <code>Secrets__EncryptionKey</code> 才能加密保存,值为 Base64 编码的 32 字节密钥。</p>
|
<p class="key-note">保存时由服务端自动加密,不需要额外配置加密密钥。</p>
|
||||||
</template>
|
</template>
|
||||||
<a-alert v-else type="info" message="只有超级管理员可以替换或删除 API Key。" show-icon />
|
<a-alert v-else type="info" message="只有超级管理员可以替换或删除 API Key。" show-icon />
|
||||||
</section>
|
</section>
|
||||||
@@ -234,7 +234,6 @@ function sourceLabel(source: ApiKeyState['source']) {
|
|||||||
.key-status strong { font-variant-numeric: tabular-nums; letter-spacing: .04em; }
|
.key-status strong { font-variant-numeric: tabular-nums; letter-spacing: .04em; }
|
||||||
.key-editor { display: grid; grid-template-columns: minmax(240px, 1fr) auto auto; gap: 10px; }
|
.key-editor { display: grid; grid-template-columns: minmax(240px, 1fr) auto auto; gap: 10px; }
|
||||||
.key-note { margin: 10px 0 0; color: #5e6772; font-size: 12px; }
|
.key-note { margin: 10px 0 0; color: #5e6772; font-size: 12px; }
|
||||||
.key-note code { color: #414eb8; }
|
|
||||||
.result-alert { margin-bottom: 18px; }
|
.result-alert { margin-bottom: 18px; }
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.page-heading { flex-direction: column; }
|
.page-heading { flex-direction: column; }
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ public sealed class LlmSecretProtectorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Protect_RoundTripsWithoutEmbeddingPlaintext()
|
public void Protect_RoundTripsWithoutEmbeddingPlaintext()
|
||||||
{
|
{
|
||||||
var encryptionKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
|
||||||
var configuration = new ConfigurationBuilder()
|
var configuration = new ConfigurationBuilder()
|
||||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
{
|
{
|
||||||
["Secrets:EncryptionKey"] = encryptionKey,
|
["Jwt:Secret"] = Convert.ToBase64String(
|
||||||
|
RandomNumberGenerator.GetBytes(48)),
|
||||||
})
|
})
|
||||||
.Build();
|
.Build();
|
||||||
var protector = new LlmSecretProtector(configuration);
|
var protector = new LlmSecretProtector(configuration);
|
||||||
@@ -26,14 +26,14 @@ public sealed class LlmSecretProtectorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Protect_RejectsMissingEncryptionKey()
|
public void Protect_RejectsMissingServerSecret()
|
||||||
{
|
{
|
||||||
var protector = new LlmSecretProtector(new ConfigurationBuilder().Build());
|
var protector = new LlmSecretProtector(new ConfigurationBuilder().Build());
|
||||||
|
|
||||||
var exception = Assert.Throws<InvalidOperationException>(
|
var exception = Assert.Throws<InvalidOperationException>(
|
||||||
() => protector.Protect("sk-test"));
|
() => protector.Protect("sk-test"));
|
||||||
|
|
||||||
Assert.Contains("Secrets__EncryptionKey", exception.Message);
|
Assert.Contains("JWT 密钥", exception.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -65,13 +65,11 @@ public sealed class LlmSecretProtector(IConfiguration configuration)
|
|||||||
|
|
||||||
private byte[] ReadEncryptionKey()
|
private byte[] ReadEncryptionKey()
|
||||||
{
|
{
|
||||||
var raw = configuration["Secrets:EncryptionKey"];
|
var jwtSecret = configuration["Jwt:Secret"];
|
||||||
byte[]? key = null;
|
if (string.IsNullOrWhiteSpace(jwtSecret) || jwtSecret.Length < 32)
|
||||||
try { key = string.IsNullOrWhiteSpace(raw) ? null : Convert.FromBase64String(raw); }
|
throw new InvalidOperationException("服务端 JWT 密钥配置无效,无法保护 API Key");
|
||||||
catch (FormatException) { }
|
return HMACSHA256.HashData(
|
||||||
if (key?.Length != 32)
|
Encoding.UTF8.GetBytes(jwtSecret),
|
||||||
throw new InvalidOperationException(
|
Encoding.UTF8.GetBytes("jizhi:llm-api-key-encryption:v1"));
|
||||||
"请通过 Secrets__EncryptionKey 配置 base64 编码的 32 字节密钥后再保存 API Key");
|
|
||||||
return key;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-11
@@ -45,7 +45,6 @@
|
|||||||
cd backend
|
cd backend
|
||||||
export Admin__BootstrapUsername='admin'
|
export Admin__BootstrapUsername='admin'
|
||||||
export Admin__BootstrapPassword='replace-with-a-password-longer-than-5-characters'
|
export Admin__BootstrapPassword='replace-with-a-password-longer-than-5-characters'
|
||||||
export Secrets__EncryptionKey='base64-encoded-32-byte-key'
|
|
||||||
dotnet build
|
dotnet build
|
||||||
# 重启
|
# 重启
|
||||||
powershell -Command "Get-Process dotnet | Stop-Process -Force"
|
powershell -Command "Get-Process dotnet | Stop-Process -Force"
|
||||||
@@ -57,16 +56,9 @@ dotnet run --project MiaoJiZhang.Api
|
|||||||
引导变量。正式环境必须使用 HTTPS 并保持 `Admin__CookieSecure=true`。本地纯 HTTP 调试时才可
|
引导变量。正式环境必须使用 HTTPS 并保持 `Admin__CookieSecure=true`。本地纯 HTTP 调试时才可
|
||||||
临时设置 `Admin__CookieSecure=false`。
|
临时设置 `Admin__CookieSecure=false`。
|
||||||
|
|
||||||
后台“AI 配置 → 模型服务”可以保存和替换 LLM API Key。实际 API Key 使用 AES-GCM
|
后台“AI 配置 → 模型服务”可以直接保存和替换 LLM API Key。实际 API Key 使用 AES-GCM
|
||||||
加密后写入配置表,服务端只需通过 `Secrets__EncryptionKey` 提供一个固定的 32 字节
|
加密后写入配置表,加密密钥由服务端从必填的 `Jwt__Secret` 自动派生,无需增加部署变量;
|
||||||
加密主密钥;页面和接口只显示 API Key 尾号。可使用 PowerShell 生成:
|
页面和接口只显示 API Key 尾号。旧的 `LLM_API_KEY` 仍作为回退配置,后台保存的密钥优先。
|
||||||
|
|
||||||
```powershell
|
|
||||||
[Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))
|
|
||||||
```
|
|
||||||
|
|
||||||
请将该值保存到部署平台的密钥管理中,不要提交到仓库。更换或丢失主密钥会导致后台已保存的
|
|
||||||
LLM API Key 无法解密。旧的 `LLM_API_KEY` 仍作为回退配置;后台保存的密钥优先。
|
|
||||||
|
|
||||||
### 2. Admin Web
|
### 2. Admin Web
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
Reference in New Issue
Block a user