Repository Wiki
BeyondDimension/SteamTools

安全与数据保护

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 账户令牌、加速器配置中的凭据等。直接明文写入磁盘会带来被恶意软件或人为读取的风险,因此客户端引入了两级数据保护策略:

  1. 内嵌 AES 密钥保护(Embedded AES):密钥随应用设置下发(Res.aes_key),在客户端中通过 AESUtils.Create(AesSecret) 构造 System.Security.Cryptography.Aes 实例。该密钥只在官方渠道包中存在——非官方渠道包缺少 AesSecret 时会抛出 IsNotOfficialChannelPackageException,从而禁用该保护路径。
  2. 操作系统级保护(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

Loading diagram...

架构说明:

  • 装配层: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

注册入口位于客户端扩展目录,默认实现等价于带泛型参数的完整注册:

csharp
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)只需要一行即可完成全部安全装配:

csharp
// 添加安全服务 services.AddSecurityService<EmbeddedAesDataProtectionProvider, LocalDataProtectionProvider>();

Source: Startup.cs

[MethodImpl(MethodImplOptions.AggressiveInlining)] 表明这是被频繁调用的薄封装,编译期即被内联以消除调用开销。

2. 内嵌 AES 数据保护提供程序:EmbeddedAesDataProtectionProvider

这是最核心的密钥读取与容错逻辑,完整实现如下(该类除构造函数外仅一个 Aes 属性):

csharp
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

逐点解析其控制流:

  1. 依赖注入:构造函数只接收 IOptions<AppSettings>,符合 Options 模式;提供程序本身不负责加载设置。
  2. 懒加载 + 缓存:Aes 属性首次访问时才从 settings.Aes 取值并缓存到字段 aes(Aes[] 数组形式,供基类支持多密钥轮换)。后续访问直接命中缓存,避免重复构造加密器。
  3. 失败记忆(负缓存):isCallGetAes 标志位记录"已经尝试过并且失败"。一旦置位,后续访问直接返回 null 而不再抛异常——这是典型的"一次降级、永久降级"策略,避免每次调用都触发异常开销和重复日志。
  4. 异常类型:IsNotOfficialChannelPackageException 由 AppSettings.Aes 内部抛出(见下节),语义是"当前包不是官方渠道包,缺少内嵌密钥"。日志以 ApiRspCode.IsNotOfficialChannelPackage 作为错误码记录,便于运维按错误码排查非官方渠道问题。

为什么返回 Aes[]? 而不是单个 Aes? 数组形态允许基类在尝试解密历史数据时按顺序尝试多把密钥(密钥轮换场景),返回 null 则表示该保护维度整体不可用。

3. 本地数据保护提供程序:LocalDataProtectionProvider

csharp
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 承载密钥材料,相关片段:

csharp
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

csharp
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 平台兼容性抑制),属于构建期密码解密,不在运行时子系统范围内,此处仅作交叉提示。

核心流程:一次本地敏感数据的保护与还原

Loading diagram...

流程要点:

  1. 启动期:DI 容器完成两个提供程序的注册,调用方无需感知具体实现。
  2. AES 路径:首次访问 Aes 时构造并缓存;失败则进入"负缓存"降级,之后不再重试。这一顺序保证官方渠道包在首次访问即获得加密能力,而非官方包快速失败且不污染后续日志。
  3. 本地保护路径:MachineSecretKey 由平台服务实时提供(而非构造期固定),意味着平台层可以在运行期根据设备状态刷新密钥;DPAPI 负责最终字节级封装。
  4. 还原路径与保护路径对称(Unprotect + 相同熵 + 相同作用域),数据仅在生成它的同一台机器上可还原。

使用示例

示例 1:在客户端启动时装配安全服务(标准用法)

csharp
// 添加安全服务 services.AddSecurityService<EmbeddedAesDataProtectionProvider, LocalDataProtectionProvider>();

Source: Startup.cs

这是唯一的官方装配点。无参扩展方法 AddSecurityService() 内部即调用此泛型版本,因此自定义宿主(如测试工程)可以直接使用无参重载获得与正式客户端一致的安全栈。

示例 2:实现基于内嵌 AES 密钥的自定义提供程序(扩展点用法)

以现有 EmbeddedAesDataProtectionProvider 为模板,替换密钥来源即可扩展新的保护维度:

csharp
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.AesSecretbyte[]?nullAES 原始密钥字节,序列化键为 "2";来源于服务端响应 Res.aes_key。为 null 时触发非官方渠道包异常
AppSettings.RSASecretbyte[]?null非对称密钥材料,与 AesSecret 同批从 Res 赋值
AppSettings.AesAes(运行期派生)懒加载构造[S_JsonIgnore],由 AESUtils.Create(AesSecret) 生成并缓存的加密器实例
AddSecurityService<TEmbeddedAes, TLocal> 泛型参数类型参数EmbeddedAesDataProtectionProvider / LocalDataProtectionProvider决定内嵌 AES 与本地保护两个维度的具体实现
MachineSecretKey(byte[] key, byte[] iv)平台相关由 IPlatformService 提供的机器级密钥二元组,作为本地保护熵
DataProtectionScope枚举LocalMachineWindows 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 并返回 nullEmbeddedAesDataProtectionProvider.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 无额外持久化:机器密钥由操作系统托管,应用无需自行安全存储主密钥。

扩展点

  1. 更换密钥来源:继承 EmbeddedAesDataProtectionProviderBase 并重写 Aes,将 settings.Aes 换成任何密钥源(HSM、远程 KMS 等)。
  2. 更换本地保护实现:继承 LocalDataProtectionProviderBase 并重写 MachineSecretKey,或直接实现 IProtectedData 以接入新平台。
  3. 整体替换装配:通过 AddSecurityService<TEmbeddedAes, TLocal>() 泛型参数注入自定义组合,无需改动调用方代码。
  4. 空实现回退:EmptyLocalDataProtectionProvider 展示了在不支持平台上保持接口契约完整的做法。

测试

本仓库可见范围内未检索到针对上述安全类的专用单元测试文件;测试策略上,建议依赖 AddSecurityService 的泛型装配点注入测试替身(如密钥固定的 EmbeddedAesDataProtectionProviderBase 子类)以验证加解密往返。此为基于源码可见范围的结论,完整测试可能位于仓库外或未包含在当前分支。

Sources

(3 files)
src/BD.WTTS.Client/Extensions/Security
src/BD.WTTS.Client/Services.Implementation/Security