name: dotnet-secrets-management description: “管理机密和敏感配置。用户机密、环境变量、轮换。” user-invocable: false
dotnet机密管理
适用于.NET应用程序的云无关机密管理。涵盖完整生命周期:本地开发使用用户机密,生产环境使用环境变量,IConfiguration绑定模式,机密轮换,以及托管身份作为生产最佳实践。包括应避免的反模式(源代码中的机密、appsettings.json中的机密、硬编码连接字符串)。
范围
- 本地开发的用户机密
- 生产环境的环境变量
- 机密的IConfiguration绑定模式
- 机密轮换策略
- 托管身份作为生产最佳实践
- 应避免的反模式(源代码中的机密、appsettings.json中的机密)
超出范围
- 云提供商特定的保险库服务(Azure Key Vault、AWS Secrets Manager、GCP Secret Manager)——参见[skill:dotnet-advisor]
- 身份验证/授权实现(OAuth、Identity)——参见[skill:dotnet-api-security]和[skill:dotnet-blazor-auth]
- 加密算法选择——参见[skill:dotnet-cryptography]
- 通用选项模式和配置源——参见[skill:dotnet-csharp-configuration]
交叉引用:[skill:dotnet-security-owasp]用于OWASP A02(加密失败)和已弃用模式警告,[skill:dotnet-csharp-configuration]用于选项模式和配置源优先级。
机密生命周期
| 环境 | 机密源 | 机制 |
|---|---|---|
| 本地开发 | 用户机密 | dotnet user-secrets CLI,仓库外的secrets.json |
| CI/CD | 流水线变量 | 作为环境变量注入,永远不在YAML中 |
| 暂存/生产 | 环境变量或保险库 | 操作系统级环境变量、托管身份或保险库提供商 |
原则: 机密绝不能存在于源代码仓库或任何提交到版本控制的文件中。每个环境层使用适合其信任边界的机制。
用户机密(本地开发)
用户机密将敏感配置存储在项目目录外的用户配置文件中,防止意外提交。
设置
# 为项目初始化用户机密(在csproj中创建UserSecretsId)
dotnet user-secrets init
# 设置单个机密
dotnet user-secrets set "ConnectionStrings:DefaultDb" "Server=localhost;Database=myapp;User=sa;Password=dev123"
dotnet user-secrets set "Smtp:ApiKey" "SG.dev-key-here"
dotnet user-secrets set "Jwt:SigningKey" "dev-signing-key-min-32-chars-long!!"
# 列出当前机密
dotnet user-secrets list
# 移除机密
dotnet user-secrets remove "Smtp:ApiKey"
# 清除所有机密
dotnet user-secrets clear
工作原理
用户机密存储在:
- Windows:
%APPDATA%\Microsoft\UserSecrets\<UserSecretsId>\secrets.json - macOS/Linux:
~/.microsoft/usersecrets/<UserSecretsId>/secrets.json
secrets.json文件是纯JSON,结构与appsettings.json相同:
{
"ConnectionStrings": {
"DefaultDb": "Server=localhost;Database=myapp;User=sa;Password=dev123"
},
"Smtp": {
"ApiKey": "SG.dev-key-here"
},
"Jwt": {
"SigningKey": "dev-signing-key-min-32-chars-long!!"
}
}
在代码中加载
当DOTNET_ENVIRONMENT或ASPNETCORE_ENVIRONMENT设置为Development时,WebApplication.CreateBuilder和Host.CreateDefaultBuilder自动加载用户机密:
var builder = WebApplication.CreateBuilder(args);
// 用户机密已自动加载。通过IConfiguration访问:
var connectionString = builder.Configuration.GetConnectionString("DefaultDb");
对于非Web主机(控制台应用、工作服务):
var builder = Host.CreateApplicationBuilder(args);
// 在开发环境中自动加载用户机密。
// 用于显式控制:
if (builder.Environment.IsDevelopment())
{
builder.Configuration.AddUserSecrets<Program>();
}
注意事项: 用户机密未加密——仅存储在仓库外。仅适用于开发,绝不用于生产。
环境变量(生产环境)
环境变量是标准机制,用于在不接触文件系统的情况下将机密注入生产应用程序。
配置优先级
在默认的ASP.NET Core配置栈中,环境变量覆盖基于文件的源(最后胜出):
appsettings.jsonappsettings.{Environment}.json- 用户机密(仅开发环境)
- 环境变量(覆盖以上所有)
- 命令行参数
映射约定
.NET使用__(双下划线)作为节分隔符将环境变量映射到配置键:
# 这些环境变量映射到配置节:
export ConnectionStrings__DefaultDb="Server=prod-db;Database=myapp;..."
export Smtp__ApiKey="SG.production-key"
export Jwt__SigningKey="production-signing-key-256-bits"
# 带前缀(推荐以避免冲突):
export MYAPP_ConnectionStrings__DefaultDb="Server=prod-db;..."
// 加载带前缀的环境变量
builder.Configuration.AddEnvironmentVariables(prefix: "MYAPP_");
// 访问方式与任何配置源相同:
var smtpKey = builder.Configuration["Smtp:ApiKey"];
容器环境
# docker-compose.yml —— 通过环境注入机密
services:
api:
image: myapp:latest
environment:
- ConnectionStrings__DefaultDb=Server=db;Database=myapp;User=sa;Password=${DB_PASSWORD}
- Smtp__ApiKey=${SMTP_API_KEY}
env_file:
- .env # 不提交到源代码控制
# Dockerfile —— 不要将机密烘焙到镜像中
# 在运行时使用环境变量代替
ENV ASPNETCORE_URLS=http://+:8080
# 永远不要:ENV ConnectionStrings__DefaultDb="Server=..."
注意事项: 环境变量对同一用户下的所有进程可见。在多租户容器环境中,使用容器级隔离(Kubernetes机密、Docker机密)而非主机级环境变量。
IConfiguration绑定模式
将机密绑定到强类型选项类,以获得编译时安全和验证。
public sealed class JwtOptions
{
public const string SectionName = "Jwt";
[Required, MinLength(32)]
public string SigningKey { get; set; } = "";
/// <summary>
/// 轮换窗口期间保留的先前签名密钥。
/// 设置此项,以便使用旧密钥签名的令牌在过期前保持有效。轮换完成后移除。
/// </summary>
public string? PreviousSigningKey { get; set; }
[Required]
public string Issuer { get; set; } = "";
[Required]
public string Audience { get; set; } = "";
[Range(1, 1440)]
public int ExpirationMinutes { get; set; } = 60;
}
// 带验证的注册
builder.Services
.AddOptions<JwtOptions>()
.BindConfiguration(JwtOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart(); // 如果机密缺失,快速失败
// 注入和使用
public sealed class TokenService(IOptions<JwtOptions> jwtOptions)
{
private readonly JwtOptions _jwt = jwtOptions.Value;
public string GenerateToken(string userId)
{
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_jwt.SigningKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _jwt.Issuer,
audience: _jwt.Audience,
claims: [new Claim(ClaimTypes.NameIdentifier, userId)],
expires: DateTime.UtcNow.AddMinutes(_jwt.ExpirationMinutes),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
选项类必须使用
{ get; set; }(而非{ get; init; }),因为配置绑定器和PostConfigure需要在构造后修改属性。使用数据注解属性([Required]、[MinLength])进行验证。
注意事项: ValidateOnStart()在应用程序启动时而非首次使用时捕获缺失的机密。始终为承载机密的选项使用它,以快速失败并提供清晰错误信息。
机密轮换
设计应用程序以处理机密轮换而无需停机。
轮换友好模式
// 对可能在运行时更改的机密使用IOptionsMonitor<T>
public sealed class EmailService(IOptionsMonitor<SmtpOptions> smtpOptions, ILogger<EmailService> logger)
{
public async Task SendAsync(string to, string subject, string body)
{
// CurrentValue在每次调用时读取最新配置
var options = smtpOptions.CurrentValue;
logger.LogDebug("使用SMTP主机 {Host}", options.Host);
// ... 使用当前选项发送邮件 ...
}
}
// 通过托管服务审计日志配置更改。
// IHostedService始终由主机激活,因此订阅有保证。
public sealed class SmtpOptionsChangeLogger(
IOptionsMonitor<SmtpOptions> monitor,
ILogger<SmtpOptionsChangeLogger> logger) : IHostedService, IDisposable
{
private IDisposable? _subscription;
public Task StartAsync(CancellationToken cancellationToken)
{
_subscription = monitor.OnChange(options =>
{
logger.LogInformation("SMTP配置在 {Time} 重新加载", DateTime.UtcNow);
});
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public void Dispose() => _subscription?.Dispose();
}
// 注册:
builder.Services.AddHostedService<SmtpOptionsChangeLogger>();
// 用于零停机轮换的双密钥验证
// 在轮换窗口期间接受旧的和新的签名密钥
public sealed class DualKeyTokenValidator(IOptionsMonitor<JwtOptions> optionsMonitor)
{
public TokenValidationParameters GetParameters()
{
// 每次调用读取CurrentValue,以便无需重启应用程序即可拾取轮换的密钥
var options = optionsMonitor.CurrentValue;
var keys = new List<SecurityKey>
{
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.SigningKey))
};
if (!string.IsNullOrEmpty(options.PreviousSigningKey))
{
keys.Add(new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(options.PreviousSigningKey)));
}
return new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = options.Issuer,
ValidateAudience = true,
ValidAudience = options.Audience,
ValidateLifetime = true,
IssuerSigningKeys = keys
};
}
}
轮换清单
- 将新机密与旧机密一起部署(双密钥窗口)
- 更新应用程序以接受旧的和新的机密
- 滚动部署,使所有实例使用新机密进行签名/加密
- 所有客户端轮换后,移除旧机密
- 审计记录每个轮换事件
托管身份(生产最佳实践)
托管身份通过使用平台的身份系统对服务进行身份验证,完全消除机密的必要性。
概念: 应用程序无需存储带密码的连接字符串,而是使用其平台分配的身份对数据库/服务进行身份验证。无需管理、轮换或泄露机密。
// 示例:使用DefaultAzureCredential与SQL Server进行无密码连接
// 此模式适用于Azure,AWS和GCP有类似模式
var connectionString = "Server=myserver.database.windows.net;Database=mydb;Authentication=Active Directory Default";
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString));
// 连接字符串中无密码——身份从环境中解析
何时使用托管身份:
- 托管在云平台上的生产和暂存环境
- 平台支持身份联合的任何服务间通信
- 数据库连接、消息队列访问、存储访问
何时仍需机密:
- 仅支持API密钥的第三方API
- 无身份联合的遗留系统
- 本地开发(使用用户机密作为备用)
反模式
源代码中的机密
// 永远不要:源代码中的硬编码机密
private const string ApiKey = "sk-live-abc123def456"; // 错误
private const string ConnectionString = "Server=prod;Password=secret"; // 错误
修复: 使用用户机密(开发)或环境变量(生产)。参见以上章节。
appsettings.json中的机密
// 永远不要:appsettings.json中的真实凭据(提交到仓库)
{
"ConnectionStrings": {
"DefaultDb": "Server=prod-db;Password=RealPassword123!"
}
}
修复: appsettings.json应仅包含非敏感默认值。使用明显失败的占位符值:
{
"ConnectionStrings": {
"DefaultDb": "Server=localhost;Database=myapp;Integrated Security=true"
},
"Smtp": {
"ApiKey": "REPLACE_VIA_ENV_OR_USER_SECRETS"
}
}
硬编码连接字符串
// 永远不要:代码中的直接连接字符串
var connection = new SqlConnection("Server=prod-db;Database=myapp;User=sa;Password=P@ssw0rd!");
修复: 始终从IConfiguration解析连接字符串:
// 正确:从配置解析
public sealed class OrderRepository(IConfiguration configuration)
{
private readonly string _connectionString =
configuration.GetConnectionString("DefaultDb")
?? throw new InvalidOperationException("ConnectionStrings:DefaultDb未配置");
}
// 更好:使用带验证的选项模式
public sealed class OrderRepository(IOptions<DatabaseOptions> options)
{
private readonly string _connectionString = options.Value.ConnectionString;
}
记录机密
// 永远不要:记录机密值
logger.LogInformation("使用API密钥: {ApiKey}", apiKey); // 错误
logger.LogDebug("连接字符串: {Conn}", connectionString); // 错误
修复: 记录机密是否已配置,而非其值:
logger.LogInformation("API密钥配置: {IsConfigured}", !string.IsNullOrEmpty(apiKey));
logger.LogInformation("为{Server}配置数据库连接", new SqlConnectionStringBuilder(connectionString).DataSource);
代理注意事项
- 不要生成硬编码机密的代码——始终使用
IConfiguration或IOptions<T>解析机密。即使在示例中,也使用占位符值。 - 不要在
appsettings.json中放入真实机密——它提交到源代码控制。开发使用用户机密,生产使用环境变量。 - 不要在选项类上使用
{ get; init; }——配置绑定器需要可变设置器。使用带数据注解验证的{ get; set; }代替。 - 不要跳过
ValidateOnStart()——没有它,缺失的机密会在首次使用时导致运行时失败,而非清晰的启动错误。 - 不要记录机密值——记录机密是否配置(
IsConfigured: true/false)或元数据(连接字符串中的服务器名),永远不要记录值。 - 不要对轮换的机密使用
IOptions<T>——对运行时可重新加载的机密使用IOptionsMonitor<T>,以便轮换无需重启。 - 不要将机密烘焙到Docker镜像中——在容器运行时使用环境变量或挂载的机密。
先决条件
- .NET 8.0+(LTS基线)
Microsoft.Extensions.Configuration.UserSecrets(包含在ASP.NET Core SDK中;控制台应用需手动添加)Microsoft.Extensions.Options.DataAnnotations用于ValidateDataAnnotations()