令牌验证器(Authenticator)
令牌验证器是 Watt Toolkit(SteamTools)中以独立插件项目 BD.WTTS.Client.Plugins.Authenticator 形式存在的两步验证(2FA)令牌管理子系统,负责 TOTP/HOTP/Steam 令牌的本地加密存储、云端同步、导入导出、排序与显示名称维护等完整能力。
Purpose and Scope
本页覆盖令牌验证器插件的端到端实现,包括:
- 实体与持久化:
AccountPlatformAuthenticator(SQLite 表E4401864)的字段结构与列名混淆设计; - 仓储层:
AccountPlatformAuthenticatorRepository对IAccountPlatformAuthenticatorRepository的实现,重点是加密模式的推导矩阵(GetEncryptionMode2)、读路径(Convert2Async解密 + 反序列化)与写路径(ConvertAsync序列化 + 加密); - 门面层:
AuthenticatorHelper静态类对外暴露的增删改查、加密切换、导入导出、排序交换、云端同步入口; - 云端同步:通过
IMicroServiceClient.Instance.AuthenticatorClient完成的令牌推送、安全问题设置与验证流程; - 数据模型:
AuthenticatorItemModel、AuthenticatorExportDTO等导入/导出与 UI 模型在本子系统中扮演的角色。
不在本页范围内(留给同级页面):
- 具体的 UI 页面布局与 Avalonia 视图(ViewModel 属于应用 UI 层);
ISecurityService底层加解密算法的实现细节(属于 BD.Common 基础库安全服务);- 微服务后端
AuthenticatorClientAPI 的服务端实现; - Steam 交易确认(
SteamTradeConfirmationModel)的完整业务流程。
Overview
令牌验证器将多种平台的动态令牌(如 Steam 令牌、标准 TOTP/HOTP 令牌)统一抽象为 IAuthenticatorDTO,并在本地 SQLite 数据库中以密文形式持久化,防止用户直接读取数据库文件即可窃取令牌密钥。
关键概念:
| 概念 | 说明 |
|---|---|
IAuthenticatorDTO / AuthenticatorDTO | 令牌的内存模型(Id、Name、ServerId、Value、Index 等),Value 为 IAuthenticatorValueDTO(具体平台令牌值,如 SteamAuthenticator) |
AccountPlatformAuthenticator | 数据库实体,Name 与 Value 均为加密后的 byte[],Value 结构为 [EncryptionMode][[GamePlatform]Ciphertext] |
| 本机加密(isLocal) | 以本机密钥加密,IsNotLocal 标记是否未启用;启用后令牌数据无法在其它设备解密 |
| 二级密码(secondaryPassword) | 用户额外设置的口令,IsNeedSecondaryPassword 标记;启用后读取令牌需提供该口令 |
EncryptionMode | 来自 BD.Common.Services.ISecurityService 的枚举,共四种组合:EmbeddedAes、EmbeddedAesWithSecondaryPassword、EmbeddedAesWithLocal、EmbeddedAesWithSecondaryPasswordWithLocal |
ServerId | 令牌在服务端的 Guid 标识;非空表示该令牌已同步到云端,排序/删除等操作需同步调用云端 API |
| 安全问题(IndependentPassword) | 云端令牌写操作(如调整排序)所需的二次验证,通过 AuthenticatorClient.VerifyIndependentPassword 校验 |
使用场景:用户在 Watt Toolkit 中添加/导入 Steam Guard 或其它平台令牌后,插件负责加密落库;打开主界面时批量解密加载为 DTO 列表;用户可拖动排序(本地与云端索引交换)、重命名、切换加密模式、导出为 .mpo/.dat 文件,或将令牌推送到云端实现多设备同步。
Architecture
分层说明:
- 模型层:
AuthenticatorItemModel等位于插件Models目录,是 UI 绑定与导入/导出文件(SDAFileModel、SteamGuardModel、ImportFileModelJsonContext)的数据载体。 - 门面层:
AuthenticatorHelper是namespace BD.WTTS.Services下的静态类,通过Ioc.Get<IAccountPlatformAuthenticatorRepository>()拿到仓储实现(见AuthenticatorHelper.cs第 8 行),把仓储操作、文件选择器弹窗、云端 API 调用与 Toast 提示组合成面向 UI 的完整用例。 - 仓储层:
AccountPlatformAuthenticatorRepository继承通用Repository<AccountPlatformAuthenticator, ushort>,构造函数注入ISecurityService,所有加解密都委托给该服务;数据库访问统一包在AttemptAndRetry(...)重试模板中。 - 数据层:SQLite 表名与列名均使用十六进制混淆字符串(
E4401864、1DEF5924等),配合字段级加密形成纵深防御。
实体关系(持久化模型)
实体 AccountPlatformAuthenticator 同时实现 IEntity<ushort>、IOrder(Order 显式映射到 Index)与 IOrderAuthenticator,并标注 [MPObj(keyAsPropertyName: true), MP2Obj] 以支持 MemoryPack 序列化——这正是导出文件以 MsgPack/MemoryPack 二进制格式落盘的基础;而 Id 字段标注 [MPIgnore, MP2Ignore],序列化时被排除。
核心实现机制
加密模式推导:GetEncryptionMode2
仓储在写入任何令牌前都会根据 isLocal(是否本机加密)与 secondaryPassword(二级密码是否非空)推导出 EncryptionMode,这是整个子系统安全语义的核心:
1static (bool notSecondaryPassword, EncryptionMode mode) GetEncryptionMode2(bool isLocal, string? secondaryPassword)
2{
3 var notSecondaryPassword = string.IsNullOrEmpty(secondaryPassword);
4 var encryptionMode =
5 isLocal ?
6 (notSecondaryPassword ?
7 EncryptionMode.EmbeddedAesWithLocal :
8 EncryptionMode.EmbeddedAesWithSecondaryPasswordWithLocal) :
9 (notSecondaryPassword ?
10 EncryptionMode.EmbeddedAes :
11 EncryptionMode.EmbeddedAesWithSecondaryPassword);
12 return (notSecondaryPassword, encryptionMode);
13}组合矩阵:
| isLocal | secondaryPassword | EncryptionMode | 实体上的两个标记位 |
|---|---|---|---|
| true | 空 | EmbeddedAesWithLocal | IsNotLocal=false,IsNeedSecondaryPassword=false |
| true | 非空 | EmbeddedAesWithSecondaryPasswordWithLocal | IsNotLocal=false,IsNeedSecondaryPassword=true |
| false | 空 | EmbeddedAes | IsNotLocal=true,IsNeedSecondaryPassword=false |
| false | 非空 | EmbeddedAesWithSecondaryPassword | IsNotLocal=true,IsNeedSecondaryPassword=true |
设计意图:显示名称(Name)与令牌值(Value)采用不同强度。Name 永远不使用二级密码加密(GetEncryptionMode(isLocal, null)),因为列表页需要无口令显示名称;而 Value(含密钥)在设置了二级密码后必须口令解密,从而在"查看名称免口令"与"读取密钥需口令"之间取得平衡。
写路径:DTO → 加密实体
1async Task<AccountPlatformAuthenticator> ConvertAsync(IAuthenticatorDTO item, bool isLocal,
2 string? secondaryPassword = null)
3{
4 var value = Serializable.SMP(item.Value);
5
6 (var notSecondaryPassword, var encryptionMode) = GetEncryptionMode2(isLocal, secondaryPassword);
7
8 var name_encryptionMode = GetEncryptionMode(isLocal, null);
9 var name_bytes = await ss.E(item.Name ?? string.Empty, name_encryptionMode, null);
10
11 var value_bytes = await ss.EB(value, encryptionMode, secondaryPassword);
12 value_bytes = value_bytes.ThrowIsNull(nameof(value_bytes));
13
14 var entity = new AccountPlatformAuthenticator
15 {
16 Id = item.Id,
17 Name = name_bytes,
18 ServerId = item.ServerId,
19 Value = value_bytes,
20 IsNotLocal = !isLocal,
21 IsNeedSecondaryPassword = !notSecondaryPassword,
22 Index = item.Index,
23 Created = item.Created == default ? DateTimeOffset.Now : item.Created,
24 LastUpdate = DateTimeOffset.Now,
25 };
26 return entity;
27}步骤拆解:
Serializable.SMP(item.Value)将IAuthenticatorValueDTO序列化为二进制(MsgPack);ss.E(...)加密名称字符串(无二级密码模式);ss.EB(...)加密令牌二进制(可能带二级密码);最终Value列的字节布局为[EncryptionMode][[GamePlatform]Ciphertext],即密文前携带模式字节,解密端据此自描述;- 构造实体时以取反方式写入两个标记位(
IsNotLocal = !isLocal),并把Created兜底为当前时间、LastUpdate总是刷新。
随后 InsertOrUpdateAsync(item, isLocal, secondaryPassword) 调用该转换并落库,回写自增 Id,并通过 r.result == DbRowExecResult.Update 判断本次是"插入"还是"更新"返回 (isSuccess, isUpdate)。
读路径:加密实体 → DTO
1async Task<(IAuthenticatorDTO? value, ImportResultCode resultCode)> Convert2Async(AccountPlatformAuthenticator item, string? secondaryPassword)
2{
3 var (value_bytes, result_code) = await ss.DB2(item.Value, secondaryPassword);
4 if (result_code != DResultCode.Success) return (null, Convert(result_code));
5
6 var (name_str, name_result_code) = await ss.D2(item.Name, secondaryPassword);
7 if (name_result_code != DResultCode.Success) return (null, Convert(name_result_code));
8
9 IAuthenticatorValueDTO? value;
10 try
11 {
12 //TODO 谷歌云令牌的解码有问题
13 value = Serializable.DMP<IAuthenticatorValueDTO>(value_bytes!);
14 if (value == null) return (null, ImportResultCode.Success);
15 }
16 catch
17 {
18 return (null, ImportResultCode.IncorrectFormat);
19 }
20
21 var index = GetOrder(item);
22 var result = new AuthenticatorDTO
23 {
24 Id = item.Id,
25 Name = name_str ?? string.Empty,
26 ServerId = item.ServerId,
27 Value = value,
28 Index = index,
29 Created = item.Created,
30 LastUpdate = item.LastUpdate,
31 };
32 return (result, ImportResultCode.Success);
33}读路径的失败以 ImportResultCode 枚举返回(由 DResultCode 数值强制转换而来),而不是抛异常——这让批量转换 ConvertToListAsync 可以静默跳过解密失败的条目(item != null 过滤),典型场景:未提供二级密码时,带口令令牌被跳过而不阻塞列表加载。
排序兜底:GetOrder(item) 在 Index == default 时回退用自增 Id 充当排序值,保证旧数据/未排序数据仍有稳定顺序。
排序交换:交换失败自动回滚
AuthenticatorHelper.ChangeAuthenticatorIndex<T> 实现了"上移/下移"背后的索引交换,并带失败回滚重试逻辑:
1public static async Task<int> ChangeAuthenticatorIndex<T>(Func<T, IAuthenticatorDTO> convert, IReadOnlyList<T> items,
2 int oldIndex, int newIndex, string? answer = null)
3{
4 var item = items[oldIndex];
5 var item2Index = newIndex;
6 if (item2Index <= -1 || item2Index >= items.Count || oldIndex == newIndex) return 0;
7 var item2 = items[item2Index];
8 var itemC = convert(item);
9 var itemC2 = convert(item2);
10 var orderIndex = itemC.Index;
11 var orderIndex2 = itemC2.Index;
12 itemC.Index = orderIndex2;
13 itemC2.Index = orderIndex;
14 var result = (await Task.WhenAll(UpdateAuthenticatorIndex(itemC, answer), UpdateAuthenticatorIndex(itemC2, answer)))
15 .Sum();
16 if (result < 2)
17 {
18 itemC.Index = orderIndex;
19 itemC2.Index = orderIndex2;
20 result = (await Task.WhenAll(UpdateAuthenticatorIndex(itemC, answer),
21 UpdateAuthenticatorIndex(itemC2, answer)))
22 .Sum();
23 }
24 return result;
25}Source: AuthenticatorHelper.cs
设计意图:两个令牌的 Index 交换必须同时成功(result == 2);若任一失败,立即恢复原始索引并再重试一次,避免出现两条令牌排序值相同或错乱。
云端令牌的排序同步:UpdateAuthenticatorIndex
1static async Task<int> UpdateAuthenticatorIndex(IAuthenticatorDTO authenticatorDto,
2 string? answer = null)
3{
4 if (authenticatorDto.ServerId == null) return await repository.UpdateIndexByItemAsync(authenticatorDto);
5 if (string.IsNullOrEmpty(answer)) return 0;
6 var response = await IMicroServiceClient.Instance.AuthenticatorClient.SyncAuthenticatorsToCloud(new()
7 {
8 Difference = new[]
9 {
10 new UserAuthenticatorPushItem()
11 {
12 Id = authenticatorDto.ServerId,
13 Order = authenticatorDto.Index,
14 Name = authenticatorDto.Name,
15 },
16 },
17 Answer = answer,
18 });
19 response.Content.ThrowIsNull();
20 if (response.IsSuccess && response.Content.Result) return await repository.UpdateIndexByItemAsync(authenticatorDto);
21 Toast.Show(ToastIcon.Warning, AppResources.Error_UpdateCloudData);
22 return 0;
23}Source: AuthenticatorHelper.cs
分支策略:
- 纯本地令牌(
ServerId == null):直接更新本地Index; - 云令牌:缺少
answer(安全问题答案)时返回 0(拒绝操作);有答案时先调用SyncAuthenticatorsToCloud推送差异(仅含Id/Order/Name的UserAuthenticatorPushItem),云端确认成功后才更新本地索引,失败则以 Toast 提示"更新云端数据失败"。
云端安全问题验证:VerifyIndependentPassword
首次同步时若服务端尚未设置安全问题(GetIndependentPasswordQuestion 返回空且非未授权),会弹出两次输入对话框设置问题与答案并调用 SetIndependentPassword;随后每次需要时要求用户输入答案并通过 VerifyIndependentPassword 校验,答案错误时递归重试(return await VerifyIndependentPassword();)直到成功或用户取消。该答案即是 ChangeAuthenticatorIndex 中的 answer 参数来源。
Source: AuthenticatorHelper.cs
云端响应转 DTO:ConvertToAuthenticatorDto
1public static IAuthenticatorDTO ConvertToAuthenticatorDto(
2 UserAuthenticatorResponse authenticatorResponse)
3{
4 var exportDto = MemoryPackSerializer.Deserialize<AuthenticatorExportDTO>(authenticatorResponse.Token);
5 exportDto.ThrowIsNull();
6 var valueDto = ConvertToAuthenticatorValueDto(exportDto);
7 AuthenticatorDTO dto = new AuthenticatorDTO()
8 {
9 ServerId = authenticatorResponse.Id,
10 Value = valueDto,
11 Name = exportDto.Name,
12 Index = (int)authenticatorResponse.Order,
13 //LastUpdate = DateTimeOffset.Now,
14 };
15 return dto;
16}Source: AuthenticatorHelper.cs
云端令牌以 AuthenticatorExportDTO 的 MemoryPack 二进制传输(与被注释掉的旧推送代码中 MemoryPackSerializer.Serialize(item.ToExport()) 对应),再按 Platform 分派为具体令牌类型(如 AuthenticatorPlatform.Steam → SteamAuthenticator,携带 DeviceId 与 SteamData)。
Core Flow
添加/导入令牌(写路径)
读取令牌列表(读路径)
排序交换与云端同步
Source: AuthenticatorHelper.cs
Usage Examples
新增/更新令牌并落库
1public static async Task<(bool isSuccess, bool isUpdate)> AddOrUpdateSaveAuthenticatorsAsync(IAuthenticatorDTO authenticatorDto, string? password, bool isLocal)
2{
3 return await repository.InsertOrUpdateAsync(authenticatorDto, isLocal, password);
4}Source: AuthenticatorHelper.cs
注意:方法上方保留了成段的注释代码——早期版本会检查 MaxValue 数量上限与重复 SecretKey(Exists 中的 SequenceEqual(item.Value.SecretKey) 比较逻辑仍然保留在仓储中),当前版本将去重判断交由调用方(UI 层)决策,门面直接透传仓储结果。
删除单个令牌(本地 + 云端双删)
1public static async void DeleteAuth(IAuthenticatorDTO authenticatorDto)
2{
3 if (authenticatorDto.ServerId.HasValue)
4 await repository.DeleteAsync(authenticatorDto.ServerId.Value);
5 await repository.DeleteAsync(authenticatorDto.Id);
6}Source: AuthenticatorHelper.cs
对于已同步令牌先按 ServerId 删除(仓储中生成 DELETE FROM [E4401864] WHERE [C9835F84] = ? 的 SQL),再按本地 Id 删除。
导出令牌为文件
1public static async Task<SaveFileResult?> ExportAsync(string fileName, bool isLocal,
2 IEnumerable<IAuthenticatorDTO> items, string? password = null)
3{
4 SaveFileResult? exportFile = null;
5 if (CommonEssentials.IsSupportedSaveFileDialog)
6 {
7 AvaloniaFilePickerFileTypeFilter fileTypes = new AvaloniaFilePickerFileTypeFilter.Item[] {
8 new("MsgPack Files") {
9 Patterns = new[] { $"*{FileEx.MPO}", },
10 },
11 new("Data Files") {
12 Patterns = new[] { $"*{FileEx.DAT}", },
13 },
14 };
15 exportFile = await FilePicker2.SaveAsync(new PickOptions
16 {
17 FileTypes = fileTypes,
18 InitialFileName = fileName,
19 PickerTitle = "Watt Toolkit",
20 });
21 if (exportFile == null) return exportFile;
22
23 var filestream = exportFile.OpenWrite();
24
25 if (filestream.CanSeek && filestream.Position != 0) filestream.Position = 0;
26
27 await repository.ExportAsync(filestream, isLocal, password, items);
28
29 await filestream.FlushAsync();
30 await filestream.DisposeAsync();
31 }
32
33 return exportFile;
34}Source: AuthenticatorHelper.cs
导出走系统保存对话框,限定 .mpo(MsgPack)与 .dat 两种后缀;isLocal 参数决定导出内容是否保留本机加密(在其它设备将无法解密),password 决定是否以二级密码加密导出文件。Position = 0 的兜底用于防止文件流未在起始位置导致导出文件前部出现空洞。
切换加密模式(批量重加密)
1public static async Task<bool> SwitchEncryptionAuthenticators(bool hasLocal, IEnumerable<IAuthenticatorDTO>? auths, string? password = null)
2{
3 try
4 {
5 await repository.SwitchEncryptionModeAsync(hasLocal, password, auths);
6 return true;
7 }
8 catch (Exception ex)
9 {
10 Log.Error(nameof(AuthenticatorHelper), ex, nameof(SwitchEncryptionAuthenticators));
11 return false;
12 }
13}Source: AuthenticatorHelper.cs
仓储实现中,若 items == null 会先用 GetAllAsync(secondaryPassword) 解密加载全部令牌,再逐条 InsertOrUpdateAsync 以新模式重新加密落库——即"先读出明文、再按新模式写回"的全量迁移策略,期间任何异常由门面捕获并记日志。
校验二级密码
1public static async Task<bool> ValidatePassword(AccountPlatformAuthenticator sourceData, string password)
2{
3 return (await repository.ConvertToListAsync(new[] { sourceData }, password)).Any_Nullable();
4}Source: AuthenticatorHelper.cs
密码校验不比较哈希,而是尝试用该口令解密实体:解密失败返回 null 被过滤,列表为空即口令错误。这复用了读路径的解密语义,避免维护两套口令验证逻辑。
Configuration Options
本插件无独立 appsettings 配置节;运行时行为由数据行上的标记位与门面参数共同决定:
| 参数 / 字段 | 类型 | 默认语义 | 说明 |
|---|---|---|---|
isLocal / IsNotLocal | bool | 由用户首次选择 | 是否启用本机加密;IsNotLocal = !isLocal(数据库列 44FF3988) |
secondaryPassword / IsNeedSecondaryPassword | string? / bool | null = 无二级密码 | 令牌值加密口令;标记位列 4AF8A895 |
IAccountPlatformAuthenticatorRepository.MaxValue | int(接口常量) | 接口定义 | 查询上限,GetAllSourceAsync 使用 Take(MaxValue) 限制读取条数 |
| 表名 / 列名常量 | string | E4401864 / 1DEF5924 / 41B24805 / C9835F84 等 | 混淆化的 SQLite 表/列名,定义于实体类常量 |
answer(安全问题答案) | string? | null | 云令牌写操作(排序同步)必填,来自 VerifyIndependentPassword() |
API Reference
AuthenticatorHelper.AddOrUpdateSaveAuthenticatorsAsync(IAuthenticatorDTO, string?, bool): Task<(bool isSuccess, bool isUpdate)>
- 参数:
authenticatorDto令牌 DTO;password二级密码(可空);isLocal是否本机加密 - 返回:
isSuccess表示落库行数 > 0;isUpdate表示本次为更新(DbRowExecResult.Update)而非插入 - 副作用: 回写自增
Id到传入 DTO
AuthenticatorHelper.GetAllSourceAuthenticatorAsync(): Task<AccountPlatformAuthenticator[]>
- 返回: 未解密的原始实体数组(
Take(MaxValue)截断),供HasEncrypt、ValidatePassword等使用
AuthenticatorHelper.GetAllAuthenticatorsAsync(AccountPlatformAuthenticator[], string?): Task<List<IAuthenticatorDTO>>
- 参数:
source原始实体数组;password二级密码(可空) - 返回: 解密成功的 DTO 列表,失败条目被静默跳过
AuthenticatorHelper.DeleteAllAuthenticatorsAsync(): Task
- 行为: 先
GetAllSourceAsync取全部实体,再逐条DeleteAsync(item.Id)(逐行删除而非整表 DROP)
AuthenticatorHelper.SaveEditAuthNameAsync(IAuthenticatorDTO, string): Task
- 行为: 先
HasLocalAsync()探测库内是否存在本机加密令牌,再以GetEncryptionMode(isLocal, null)重新加密名称并UpdateAsync
AuthenticatorHelper.ImportAsync(string?, byte[]): Task<(ImportResultCode, IReadOnlyList<IAuthenticatorDTO>, int)>
- 行为: 透传
repository.ImportAsync(exportPassword, data),返回结果码 + 转换后的 DTO 列表 + 源条目数
AuthenticatorHelper.ExportAsync(string, bool, IEnumerable<IAuthenticatorDTO>, string?): Task<SaveFileResult?>
- 返回: 用户取消对话框时为
null;成功时为保存结果(文件已由仓储写出)
AuthenticatorHelper.HasEncrypt(AccountPlatformAuthenticator[]): (bool haslocal, bool haspassword)
- 行为: 组合
HasLocal(sourceData)与HasSecondaryPassword(sourceData),UI 据此决定是否提示输入二级密码
AuthenticatorHelper.MoveAuthenticatorIndex<T>(Func<T, IAuthenticatorDTO>, IReadOnlyList<T>, int, bool, string?): Task<int>
- 参数:
upOrDown=true上移(newIndex = index - 1),answer云令牌所需安全问题答案 - 返回: 成功更新的条数(2 = 全部成功)
AccountPlatformAuthenticatorRepository.InsertOrUpdateAsync(IAuthenticatorDTO, bool, string?): Task<(bool, bool)>
- 实现细节: 转换实体 →
InsertOrUpdateAsync(entity)→ 回写item.Id = entity.Id→return (r.rowCount > 0, r.result == DbRowExecResult.Update)
AccountPlatformAuthenticatorRepository.ConvertToListAsync(IEnumerable<AccountPlatformAuthenticator>, string?): Task<List<IAuthenticatorDTO>>
- 实现细节: 以
async IAsyncEnumerable惰性逐条解密,await foreach聚合并过滤 null
AccountPlatformAuthenticatorRepository.SwitchEncryptionModeAsync(bool, string?, IEnumerable<IAuthenticatorDTO>?): Task
- 实现细节:
items == null时先GetAllAsync(secondaryPassword)全量解密,再逐条按新加密模式InsertOrUpdateAsync重写
Failure Modes, Edge Cases & Concurrency
| 场景 | 行为 | 源码依据 |
|---|---|---|
| 二级密码错误读取令牌 | ss.DB2/ss.D2 返回非 Success,条目置 null 被跳过,不抛异常 | Convert2Async 的两个提前 return |
| 令牌二进制反序列化失败 | 捕获异常并返回 ImportResultCode.IncorrectFormat(TODO 注释指出谷歌云令牌解码存在已知问题) | Convert2Async 的 try/catch |
| 排序交换部分失败 | 内存恢复原 Index 后整体重试一次,返回实际成功数 | ChangeAuthenticatorIndex 的 if (result < 2) 分支 |
| 云端排序同步失败 | 本地索引不更新,Toast 提示 Error_UpdateCloudData,返回 0 | UpdateAuthenticatorIndex |
| 云令牌缺少安全问题答案 | 直接返回 0 拒绝操作 | UpdateAuthenticatorIndex 的 IsNullOrEmpty(answer) 检查 |
| 安全问题答案错误 | 递归重新调用 VerifyIndependentPassword() 循环重试 | VerifyIndependentPassword 尾递归 |
| 加密切换过程异常 | 门面捕获全部异常、Log.Error 记录并返回 false(不中断应用) | SwitchEncryptionAuthenticators try/catch |
| 数据库瞬时故障 | 所有表操作包在 AttemptAndRetry(...) 重试模板中,ThrowIfCancellationRequested 前置检查 | GetAllSourceAsync/HasLocalAsync 等 |
Index == default 的旧数据 | GetOrder 回退用自增 Id 作为排序值 | GetOrder |
| 导出文件流位置异常 | CanSeek 时强制 Position = 0 | ExportAsync |
| 不支持保存对话框的平台 | CommonEssentials.IsSupportedSaveFileDialog == false 时导出直接返回 null | ExportAsync |
| 令牌 SecretKey 判重 | Exists 中按 SequenceEqual(SecretKey) 异步比较 | AccountPlatformAuthenticatorRepository.Exists |
并发说明:排序交换使用 Task.WhenAll 并行下发两个更新请求(云令牌场景为两次云端 API 调用),靠"都成功才有效,否则回滚重试"保证最终一致;本地 SQLite 写入本身依赖通用 Repository 基类的连接管理与 AttemptAndRetry 模板处理锁竞争。实体上 ServerId 为可空 Guid? 且不参与主键,云端删除按 DELETE ... WHERE [C9835F84] = ? 精确匹配。
Professional Notes
- 性能:读路径逐条解密(
IAsyncEnumerable惰性 +await foreach),令牌数量通常为个位数到几十条,解密为主开销;GetAllSourceAsync以Take(MaxValue)限定读取规模。导出会一次性写出全部选中令牌。 - 运维要点:备份 SQLite 库文件时,未启用本机加密/二级密码的令牌仅受嵌入式 AES 保护,导入导出文件(
.mpo/.dat)本身即为完整令牌备份,需按敏感凭据保管。 - 扩展点:
- 新增令牌平台:实现/扩展
IAuthenticatorValueDTO,并在ConvertToAuthenticatorValueDto的switch (authenticatorExportDto.Platform)中增加分支(现有AuthenticatorPlatform.Steam→SteamAuthenticator即范例); - 新增导入来源:在
Models/AuthenticatorImportFileModels/下添加文件模型(如现有SDAFileModel、SteamGuardModel及ImportFileModelJsonContextJSON 上下文); - 插件装配:
Plugins/Plugin.cs定义本插件的注册入口,Properties/Resources.resx承载本地化字符串。
- 新增令牌平台:实现/扩展
- 测试:仓库中未发现针对本插件的独立单元测试项目(以源码文件列表为准),使用模式主要由
AuthenticatorHelper公开方法与 UI 调用关系体现。