安全与数据保护
SteamTools(Watt Toolkit)客户端内置一套分层的安全与数据保护机制,用于在本地持久化敏感数据(如 Steam 凭据、令牌、加密密钥)时提供机密性保障。该机制由 ASP.NET Core 风格的 IDataProtectionProvider 抽象、AES 对称加密提供程序(EmbeddedAesDataProtectionProvider)、操作系统级 DPAPI 封装(LocalDataProtectionProvider)以及应用设置中的密钥管理(AppSettings.Aes)共同组成。
Purpose and Scope
本页覆盖客户端侧安全与数据保护子系统的完整实现链路,包括:
- 安全服务的依赖注入注册(
AddSecurityService)与启动装配 EmbeddedAesDataProtectionProvider:基于内嵌 AES 密钥的数据保护提供程序LocalDataProtectionProvider:基于操作系统 DPAPI/平台密钥的本地数据保护AppSettings中的 AES 密钥(AesSecret/Aes)加载与官方渠道校验(IsNotOfficialChannelPackageException)- 平台层
WindowsProtectedData对 Windows DPAPI 的封装
有意留给兄弟页面的话题:
- 凭据存储、登录流程与账号管理——属于账号体系页面范围,本页仅说明其底层数据保护原语
- 网络传输层安全(HTTPS、证书校验)与 API 通信加密——属于网络层页面
- 代码签名 / Authenticode 与 MSIX 发布签名流程(
src/BD.WTTS.Client.Tools.Publish下的 PowerShell 安全代码为发布工具内部实现,不属于运行时客户端安全子系统)
Overview
客户端在本地会持久化若干敏感数据,例如缓存的 Steam 账户令牌、加速器配置中的凭据等。直接明文写入磁盘会带来被恶意软件或人为读取的风险,因此客户端引入了两级数据保护策略:
- 内嵌 AES 密钥保护(Embedded AES):密钥随应用设置下发(
Res.aes_key),在客户端中通过AESUtils.Create(AesSecret)构造System.Security.Cryptography.Aes实例。该密钥只在官方渠道包中存在——非官方渠道包缺少AesSecret时会抛出IsNotOfficialChannelPackageException,从而禁用该保护路径。 - 操作系统级保护(Local Data Protection):通过
IProtectedData抽象映射到平台原生 API。在 Windows 上是 DPAPI(ProtectedData.Protect/Unprotect,作用域为DataProtectionScope.LocalMachine),并配合IPlatformService.MachineSecretKey派生机器级密钥(key+iv)作为保护熵。
两层保护的组合方式由 AddSecurityService<EmbeddedAesDataProtectionProvider, LocalDataProtectionProvider>() 泛型参数决定:AES 提供程序负责与服务端协商/下发的数据加解密,本地保护提供程序负责与具体机器绑定的密钥封装。这种设计让同一套服务抽象既能覆盖"云端下发密钥"场景,又能覆盖"本机绑定密钥"场景,而调用方(例如设置持久化、凭据缓存)只需依赖 IDataProtectionProvider 接口即可。
关键概念:
| 概念 | 说明 |
|---|---|
IDataProtectionProvider | .NET 标准数据保护抽象,通过 CreateProtector(purpose) 创建目的隔离的保护器 |
IProtectedData | 平台无关的 DPAPI 风格抽象(Protect/Unprotect),由各平台实现 |
AesSecret | 应用设置中持久化的 AES 原始密钥字节,来源于服务端 Res.aes_key |
MachineSecretKey | 由 IPlatformService 提供的机器级 (key, iv) 二元组,作为本地保护熵 |
IsNotOfficialChannelPackageException | 非官方渠道包缺少密钥材料时抛出的异常,是官方渠道完整性的一种轻量校验手段 |
Architecture
架构说明:
- 装配层:
Startup(Avalonia 客户端入口)调用AddSecurityService<EmbeddedAesDataProtectionProvider, LocalDataProtectionProvider>(),把两个具体提供程序一次性注册进 DI 容器。泛型重载是核心扩展点,默认无参重载与之一致(见 ServiceCollectionExtensions.AddSecurityService.cs)。 - 安全服务层:两个提供程序都遵循"薄子类 + 基类承载通用逻辑"的模式——
EmbeddedAesDataProtectionProvider继承EmbeddedAesDataProtectionProviderBase,LocalDataProtectionProvider继承LocalDataProtectionProviderBase(基类位于共享库BD.Common,本仓库未包含其实现体)。子类只负责提供密钥材料来源。 - 设置与密钥层:
AppSettings是 AES 密钥的宿主。AesSecret通过[S_JsonProperty("2")]序列化持久化,Aes属性则做懒加载并缓存Aes实例;密钥来源是服务端响应中的Res.aes_key(同时还有RSASecret用于非对称场景)。 - 平台抽象层:
IProtectedData屏蔽了平台差异;Windows 实现直接调用System.Security.Cryptography.ProtectedData(DPAPI),以LocalMachine作用域保护数据。
主实现详解
1. 安全服务注册:AddSecurityService
注册入口位于客户端扩展目录,默认实现等价于带泛型参数的完整注册:
1[MethodImpl(MethodImplOptions.AggressiveInlining)]
2public static IServiceCollection AddSecurityService(this IServiceCollection services)
3{
4 services.AddSecurityService<EmbeddedAesDataProtectionProvider, LocalDataProtectionProvider>();
5 return services;
6}Source: ServiceCollectionExtensions.AddSecurityService.cs
设计意图:通过泛型参数 <TEmbeddedAes, TLocal> 同时确定两个保护维度,调用点(Startup)只需要一行即可完成全部安全装配:
// 添加安全服务
services.AddSecurityService<EmbeddedAesDataProtectionProvider, LocalDataProtectionProvider>();Source: Startup.cs
[MethodImpl(MethodImplOptions.AggressiveInlining)] 表明这是被频繁调用的薄封装,编译期即被内联以消除调用开销。
2. 内嵌 AES 数据保护提供程序:EmbeddedAesDataProtectionProvider
这是最核心的密钥读取与容错逻辑,完整实现如下(该类除构造函数外仅一个 Aes 属性):
1public class EmbeddedAesDataProtectionProvider : EmbeddedAesDataProtectionProviderBase
2{
3 const string TAG = nameof(EmbeddedAesDataProtectionProvider);
4
5 protected readonly AppSettings settings;
6
7 public EmbeddedAesDataProtectionProvider(IOptions<AppSettings> options)
8 {
9 settings = options.Value;
10 }
11
12 Aes[]? aes;
13 bool isCallGetAes;
14
15 public override Aes[]? Aes
16 {
17 get
18 {
19 if (aes != null)
20 {
21 return aes;
22 }
23 else if (isCallGetAes)
24 {
25 return null;
26 }
27 try
28 {
29 aes = new[] { settings.Aes };
30 return aes;
31 }
32 catch (IsNotOfficialChannelPackageException e)
33 {
34 isCallGetAes = true;
35 Log.Error(TAG, e, nameof(ApiRspCode.IsNotOfficialChannelPackage));
36 return null;
37 }
38 }
39 }
40}Source: EmbeddedAesDataProtectionProvider.cs
逐点解析其控制流:
- 依赖注入:构造函数只接收
IOptions<AppSettings>,符合 Options 模式;提供程序本身不负责加载设置。 - 懒加载 + 缓存:
Aes属性首次访问时才从settings.Aes取值并缓存到字段aes(Aes[]数组形式,供基类支持多密钥轮换)。后续访问直接命中缓存,避免重复构造加密器。 - 失败记忆(负缓存):
isCallGetAes标志位记录"已经尝试过并且失败"。一旦置位,后续访问直接返回null而不再抛异常——这是典型的"一次降级、永久降级"策略,避免每次调用都触发异常开销和重复日志。 - 异常类型:
IsNotOfficialChannelPackageException由AppSettings.Aes内部抛出(见下节),语义是"当前包不是官方渠道包,缺少内嵌密钥"。日志以ApiRspCode.IsNotOfficialChannelPackage作为错误码记录,便于运维按错误码排查非官方渠道问题。
为什么返回 Aes[]? 而不是单个 Aes? 数组形态允许基类在尝试解密历史数据时按顺序尝试多把密钥(密钥轮换场景),返回 null 则表示该保护维度整体不可用。
3. 本地数据保护提供程序:LocalDataProtectionProvider
1public class LocalDataProtectionProvider : LocalDataProtectionProviderBase
2{
3 readonly IPlatformService platformService;
4
5 public LocalDataProtectionProvider(
6 IProtectedData protectedData,
7 IDataProtectionProvider dataProtectionProvider,
8 IPlatformService platformService) : base(protectedData, dataProtectionProvider)
9 {
10 this.platformService = platformService;
11 }
12
13 protected override (byte[] key, byte[] iv) MachineSecretKey => platformService.MachineSecretKey;
14}Source: LocalDataProtectionProvider.cs
解析:
- 子类唯一职责是把
MachineSecretKey的来源委托给IPlatformService.MachineSecretKey,即"密钥由平台层决定"。这样 Windows/macOS/Linux 可以各自派生不同的机器密钥(例如基于设备唯一标识),而基类中的加解密流程保持不变。 - 构造参数同时注入
IProtectedData(DPAPI 风格原语)与IDataProtectionProvider(ASP.NET Core 风格保护器工厂),基类可以组合两者:先用机器密钥派生熵,再调用平台 Protect/Unprotect。 - 同目录下还有
EmptyLocalDataProtectionProvider(空实现,本页不展开),用于不支持平台保护的场景作为无操作回退,保证 DI 容器始终能解析出实现。
4. 密钥宿主:AppSettings 中的 AES 密钥
AppSettings 承载密钥材料,相关片段:
1/// <summary>
2/// AES 密钥
3/// </summary>
4[S_JsonProperty("2")]
5public byte[]? AesSecret { get; set; }
6
7Aes? aes;
8
9[S_JsonIgnore]
10public Aes Aes
11{
12 get
13 {
14 {
15 if (aes == null)
16 {
17 if (AesSecret == null) throw new IsNotOfficialChannelPackageException(nameof(Aes), new ArgumentNullException(nameof(AesSecret)));
18 aes = AESUtils.Create(AesSecret);
19 }
20 return aes;
21 }
22 }
23}Source: AppSettings.cs
关键细节:
AesSecret以byte[]形式持久化,序列化键为"2"(紧凑命名,减小设置文件体积)。它来源于服务端响应:AesSecret = Res.aes_key(同文件 L136 附近,同时赋值RSASecret = Res.rsa_key一类的非对称密钥)。Aes属性标记[S_JsonIgnore],即加密器实例本身不参与序列化;只有原始密钥字节AesSecret落盘。AesSecret == null时抛IsNotOfficialChannelPackageException,把"密钥缺失"显式归因为"非官方渠道包"。这既是防御性编程,也构成一种渠道完整性信号:被二次打包/篡改的发行版不会携带密钥,因此其数据保护功能自动失效。AESUtils.Create(AesSecret)负责从原始字节构造标准Aes实例(设置 Key/IV 等参数),实现位于共享库BD.Common,不在本仓库当前可见范围内。
5. 平台层 DPAPI 封装:WindowsProtectedData
return ProtectedData.Protect(userData, null, DataProtectionScope.LocalMachine);Source: WindowsProtectedData.cs
- 直接调用 .NET 的
ProtectedData.Protect,熵参数传null(熵的实际控制由上层MachineSecretKey配合完成),作用域选择DataProtectionScope.LocalMachine而非CurrentUser。 - 为什么选 LocalMachine? 该应用是桌面工具,可能以不同账户运行(普通用户/管理员提权),
CurrentUser作用域会导致跨账户无法解密;LocalMachine保证同一机器上任意账户均可解密,把保护边界定在"机器"而非"账户"。相应地,加密数据不能跨机器迁移,这正是"本地数据保护"的预期语义。
发布工具侧还存在另一处 DPAPI 用法(MSIXHelper.cs 中 ProtectedData.Unprotect(pwd, optionalEntropy, DataProtectionScope.LocalMachine),并带 #pragma warning disable CA1416 平台兼容性抑制),属于构建期密码解密,不在运行时子系统范围内,此处仅作交叉提示。
核心流程:一次本地敏感数据的保护与还原
流程要点:
- 启动期:DI 容器完成两个提供程序的注册,调用方无需感知具体实现。
- AES 路径:首次访问
Aes时构造并缓存;失败则进入"负缓存"降级,之后不再重试。这一顺序保证官方渠道包在首次访问即获得加密能力,而非官方包快速失败且不污染后续日志。 - 本地保护路径:
MachineSecretKey由平台服务实时提供(而非构造期固定),意味着平台层可以在运行期根据设备状态刷新密钥;DPAPI 负责最终字节级封装。 - 还原路径与保护路径对称(
Unprotect+ 相同熵 + 相同作用域),数据仅在生成它的同一台机器上可还原。
使用示例
示例 1:在客户端启动时装配安全服务(标准用法)
// 添加安全服务
services.AddSecurityService<EmbeddedAesDataProtectionProvider, LocalDataProtectionProvider>();Source: Startup.cs
这是唯一的官方装配点。无参扩展方法 AddSecurityService() 内部即调用此泛型版本,因此自定义宿主(如测试工程)可以直接使用无参重载获得与正式客户端一致的安全栈。
示例 2:实现基于内嵌 AES 密钥的自定义提供程序(扩展点用法)
以现有 EmbeddedAesDataProtectionProvider 为模板,替换密钥来源即可扩展新的保护维度:
1public class EmbeddedAesDataProtectionProvider : EmbeddedAesDataProtectionProviderBase
2{
3 protected readonly AppSettings settings;
4
5 public EmbeddedAesDataProtectionProvider(IOptions<AppSettings> options)
6 {
7 settings = options.Value;
8 }
9
10 Aes[]? aes;
11 bool isCallGetAes;
12
13 public override Aes[]? Aes
14 {
15 get
16 {
17 if (aes != null)
18 {
19 return aes;
20 }
21 else if (isCallGetAes)
22 {
23 return null;
24 }
25 try
26 {
27 aes = new[] { settings.Aes };
28 return aes;
29 }
30 catch (IsNotOfficialChannelPackageException e)
31 {
32 isCallGetAes = true;
33 Log.Error(TAG, e, nameof(ApiRspCode.IsNotOfficialChannelPackage));
34 return null;
35 }
36 }
37 }
38}Source: EmbeddedAesDataProtectionProvider.cs
扩展该体系时应保留三个要素:IOptions<T> 注入密钥宿主、懒加载缓存、isCallGetAes 式的失败记忆,以维持与现有降级语义一致。
Configuration Options
本子系统的"配置"主要体现为设置模型中的密钥字段与 DI 装配参数,而非独立配置节:
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
AppSettings.AesSecret | byte[]? | null | AES 原始密钥字节,序列化键为 "2";来源于服务端响应 Res.aes_key。为 null 时触发非官方渠道包异常 |
AppSettings.RSASecret | byte[]? | null | 非对称密钥材料,与 AesSecret 同批从 Res 赋值 |
AppSettings.Aes | Aes(运行期派生) | 懒加载构造 | [S_JsonIgnore],由 AESUtils.Create(AesSecret) 生成并缓存的加密器实例 |
AddSecurityService<TEmbeddedAes, TLocal> 泛型参数 | 类型参数 | EmbeddedAesDataProtectionProvider / LocalDataProtectionProvider | 决定内嵌 AES 与本地保护两个维度的具体实现 |
MachineSecretKey | (byte[] key, byte[] iv) | 平台相关 | 由 IPlatformService 提供的机器级密钥二元组,作为本地保护熵 |
DataProtectionScope | 枚举 | LocalMachine | Windows DPAPI 作用域,跨账户可解密、跨机器不可解密 |
API Reference
ServiceCollectionExtensions.AddSecurityService(this IServiceCollection services): IServiceCollection
注册默认安全服务组合(EmbeddedAesDataProtectionProvider + LocalDataProtectionProvider)。
Parameters:
services(IServiceCollection): 待注册的服务集合
Returns: 同一 IServiceCollection 实例,支持链式调用
Remarks: 标记 AggressiveInlining;泛型重载 AddSecurityService<TEmbeddedAes, TLocal>() 是实际承载逻辑的版本,本仓库可见无参版本。
Source: ServiceCollectionExtensions.AddSecurityService.cs
EmbeddedAesDataProtectionProvider.Aes: Aes[]?(override 属性)
返回可用的 AES 加密器数组,供基类执行加解密。
Returns:
Aes[]:包含单个由settings.Aes构造的实例(首次访问时创建并缓存)null:密钥不可用(非官方渠道包)且已尝试过一次后
Throws(内部捕获,不外抛):
IsNotOfficialChannelPackageException:AesSecret == null时由AppSettings.Aes抛出,本属性捕获后记录日志并返回null
Source: EmbeddedAesDataProtectionProvider.cs
LocalDataProtectionProvider.MachineSecretKey: (byte[] key, byte[] iv)(override 属性)
本地保护的机器密钥来源,委托给 IPlatformService.MachineSecretKey。
Returns: key/iv 字节数组二元组,用作本地数据保护的密钥与初始向量材料
Source: LocalDataProtectionProvider.cs
WindowsProtectedData(IProtectedData 的 Windows 实现)
Protect(userData, entropy) 调用 ProtectedData.Protect(userData, null, DataProtectionScope.LocalMachine),Unprotect 对称还原。
Source: WindowsProtectedData.cs
Professional Notes
失败模式与边界情况
| 场景 | 行为 | 依据 |
|---|---|---|
AesSecret 为 null(非官方渠道包/密钥未下发) | AppSettings.Aes 抛 IsNotOfficialChannelPackageException;EmbeddedAesDataProtectionProvider.Aes 捕获后置 isCallGetAes = true,记录错误码 ApiRspCode.IsNotOfficialChannelPackage 并返回 null | EmbeddedAesDataProtectionProvider.cs L36-L41 |
| 密钥获取失败后的重复调用 | 直接返回 null(负缓存),不再抛异常、不再刷日志 | 同上 isCallGetAes 分支 |
| 数据被复制到另一台机器 | DPAPI LocalMachine 作用域下 Unprotect 失败,数据不可还原 | WindowsProtectedData 的作用域选择 |
| 同机不同账户访问 | 可正常解密(LocalMachine 而非 CurrentUser) | 同上 |
| 平台不支持原生保护 | 可切换为 EmptyLocalDataProtectionProvider 空实现,保证 DI 解析不失败 | EmptyLocalDataProtectionProvider.cs |
并发与一致性
EmbeddedAesDataProtectionProvider.Aes的懒加载模式(check-then-act)在极端并发下可能重复构造一次Aes实例,但最终字段aes会被后写者覆盖,语义上幂等无害。该路径在客户端 UI 线程为主的场景下竞争窗口极小,属可接受的取舍。AppSettings.Aes同样采用非锁懒加载,风险与上一致。MachineSecretKey每次属性访问都从IPlatformService实时读取,基类若在加解密之间缓存该值需自行保证一致性。
性能与运维
- 热路径缓存:AES 加密器构造(含密钥扩展)成本较高,因此两处懒加载 + 字段缓存(
aes、Aes)把该成本摊销到进程生命周期一次。 - 负缓存降低日志噪声:非官方渠道包不会因每次数据保护调用而反复产生异常与错误日志。
- 错误码可观测性:日志统一携带
ApiRspCode.IsNotOfficialChannelPackage,运维可据此统计非官方渠道分布。 - DPAPI 无额外持久化:机器密钥由操作系统托管,应用无需自行安全存储主密钥。
扩展点
- 更换密钥来源:继承
EmbeddedAesDataProtectionProviderBase并重写Aes,将settings.Aes换成任何密钥源(HSM、远程 KMS 等)。 - 更换本地保护实现:继承
LocalDataProtectionProviderBase并重写MachineSecretKey,或直接实现IProtectedData以接入新平台。 - 整体替换装配:通过
AddSecurityService<TEmbeddedAes, TLocal>()泛型参数注入自定义组合,无需改动调用方代码。 - 空实现回退:
EmptyLocalDataProtectionProvider展示了在不支持平台上保持接口契约完整的做法。
测试
本仓库可见范围内未检索到针对上述安全类的专用单元测试文件;测试策略上,建议依赖 AddSecurityService 的泛型装配点注入测试替身(如密钥固定的 EmbeddedAesDataProtectionProviderBase 子类)以验证加解密往返。此为基于源码可见范围的结论,完整测试可能位于仓库外或未包含在当前分支。
Related Links
- 源码入口:Startup.cs、EmbeddedAesDataProtectionProvider.cs、LocalDataProtectionProvider.cs、AppSettings.cs、WindowsProtectedData.cs
- 账号凭据的存取与登录流程:属于账号体系主题(本页仅提供其底层保护原语)
- 网络传输与 API 通信安全:属于网络层主题
- 基类
EmbeddedAesDataProtectionProviderBase/LocalDataProtectionProviderBase/AESUtils位于共享库BD.Common,不在本仓库当前可见源码范围内,其实现细节以该库为准