Repository Wiki
BeyondDimension/SteamTools

令牌验证器(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 基础库安全服务);
  • 微服务后端 AuthenticatorClient API 的服务端实现;
  • 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

Loading diagram...

分层说明:

  1. 模型层:AuthenticatorItemModel 等位于插件 Models 目录,是 UI 绑定与导入/导出文件(SDAFileModel、SteamGuardModel、ImportFileModelJsonContext)的数据载体。
  2. 门面层:AuthenticatorHelper 是 namespace BD.WTTS.Services 下的静态类,通过 Ioc.Get<IAccountPlatformAuthenticatorRepository>() 拿到仓储实现(见 AuthenticatorHelper.cs 第 8 行),把仓储操作、文件选择器弹窗、云端 API 调用与 Toast 提示组合成面向 UI 的完整用例。
  3. 仓储层:AccountPlatformAuthenticatorRepository 继承通用 Repository<AccountPlatformAuthenticator, ushort>,构造函数注入 ISecurityService,所有加解密都委托给该服务;数据库访问统一包在 AttemptAndRetry(...) 重试模板中。
  4. 数据层:SQLite 表名与列名均使用十六进制混淆字符串(E4401864、1DEF5924 等),配合字段级加密形成纵深防御。

实体关系(持久化模型)

Loading diagram...

实体 AccountPlatformAuthenticator 同时实现 IEntity<ushort>、IOrder(Order 显式映射到 Index)与 IOrderAuthenticator,并标注 [MPObj(keyAsPropertyName: true), MP2Obj] 以支持 MemoryPack 序列化——这正是导出文件以 MsgPack/MemoryPack 二进制格式落盘的基础;而 Id 字段标注 [MPIgnore, MP2Ignore],序列化时被排除。

核心实现机制

加密模式推导:GetEncryptionMode2

仓储在写入任何令牌前都会根据 isLocal(是否本机加密)与 secondaryPassword(二级密码是否非空)推导出 EncryptionMode,这是整个子系统安全语义的核心:

csharp
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}

Source: AccountPlatformAuthenticatorRepository.cs

组合矩阵:

isLocalsecondaryPasswordEncryptionMode实体上的两个标记位
true空EmbeddedAesWithLocalIsNotLocal=false,IsNeedSecondaryPassword=false
true非空EmbeddedAesWithSecondaryPasswordWithLocalIsNotLocal=false,IsNeedSecondaryPassword=true
false空EmbeddedAesIsNotLocal=true,IsNeedSecondaryPassword=false
false非空EmbeddedAesWithSecondaryPasswordIsNotLocal=true,IsNeedSecondaryPassword=true

设计意图:显示名称(Name)与令牌值(Value)采用不同强度。Name 永远不使用二级密码加密(GetEncryptionMode(isLocal, null)),因为列表页需要无口令显示名称;而 Value(含密钥)在设置了二级密码后必须口令解密,从而在"查看名称免口令"与"读取密钥需口令"之间取得平衡。

写路径:DTO → 加密实体

csharp
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}

Source: AccountPlatformAuthenticatorRepository.cs

步骤拆解:

  1. Serializable.SMP(item.Value) 将 IAuthenticatorValueDTO 序列化为二进制(MsgPack);
  2. ss.E(...) 加密名称字符串(无二级密码模式);
  3. ss.EB(...) 加密令牌二进制(可能带二级密码);最终 Value 列的字节布局为 [EncryptionMode][[GamePlatform]Ciphertext],即密文前携带模式字节,解密端据此自描述;
  4. 构造实体时以取反方式写入两个标记位(IsNotLocal = !isLocal),并把 Created 兜底为当前时间、LastUpdate 总是刷新。

随后 InsertOrUpdateAsync(item, isLocal, secondaryPassword) 调用该转换并落库,回写自增 Id,并通过 r.result == DbRowExecResult.Update 判断本次是"插入"还是"更新"返回 (isSuccess, isUpdate)。

读路径:加密实体 → DTO

csharp
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}

Source: AccountPlatformAuthenticatorRepository.cs

读路径的失败以 ImportResultCode 枚举返回(由 DResultCode 数值强制转换而来),而不是抛异常——这让批量转换 ConvertToListAsync 可以静默跳过解密失败的条目(item != null 过滤),典型场景:未提供二级密码时,带口令令牌被跳过而不阻塞列表加载。

排序兜底:GetOrder(item) 在 Index == default 时回退用自增 Id 充当排序值,保证旧数据/未排序数据仍有稳定顺序。

排序交换:交换失败自动回滚

AuthenticatorHelper.ChangeAuthenticatorIndex<T> 实现了"上移/下移"背后的索引交换,并带失败回滚重试逻辑:

csharp
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

csharp
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

csharp
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

添加/导入令牌(写路径)

Loading diagram...

读取令牌列表(读路径)

Loading diagram...

Source: AccountPlatformAuthenticatorRepository.cs

排序交换与云端同步

Loading diagram...

Source: AuthenticatorHelper.cs

Usage Examples

新增/更新令牌并落库

csharp
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 层)决策,门面直接透传仓储结果。

删除单个令牌(本地 + 云端双删)

csharp
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 删除。

导出令牌为文件

csharp
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 的兜底用于防止文件流未在起始位置导致导出文件前部出现空洞。

切换加密模式(批量重加密)

csharp
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 以新模式重新加密落库——即"先读出明文、再按新模式写回"的全量迁移策略,期间任何异常由门面捕获并记日志。

校验二级密码

csharp
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 / IsNotLocalbool由用户首次选择是否启用本机加密;IsNotLocal = !isLocal(数据库列 44FF3988)
secondaryPassword / IsNeedSecondaryPasswordstring? / boolnull = 无二级密码令牌值加密口令;标记位列 4AF8A895
IAccountPlatformAuthenticatorRepository.MaxValueint(接口常量)接口定义查询上限,GetAllSourceAsync 使用 Take(MaxValue) 限制读取条数
表名 / 列名常量stringE4401864 / 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,返回 0UpdateAuthenticatorIndex
云令牌缺少安全问题答案直接返回 0 拒绝操作UpdateAuthenticatorIndex 的 IsNullOrEmpty(answer) 检查
安全问题答案错误递归重新调用 VerifyIndependentPassword() 循环重试VerifyIndependentPassword 尾递归
加密切换过程异常门面捕获全部异常、Log.Error 记录并返回 false(不中断应用)SwitchEncryptionAuthenticators try/catch
数据库瞬时故障所有表操作包在 AttemptAndRetry(...) 重试模板中,ThrowIfCancellationRequested 前置检查GetAllSourceAsync/HasLocalAsync 等
Index == default 的旧数据GetOrder 回退用自增 Id 作为排序值GetOrder
导出文件流位置异常CanSeek 时强制 Position = 0ExportAsync
不支持保存对话框的平台CommonEssentials.IsSupportedSaveFileDialog == false 时导出直接返回 nullExportAsync
令牌 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 及 ImportFileModelJsonContext JSON 上下文);
    • 插件装配:Plugins/Plugin.cs 定义本插件的注册入口,Properties/Resources.resx 承载本地化字符串。
  • 测试:仓库中未发现针对本插件的独立单元测试项目(以源码文件列表为准),使用模式主要由 AuthenticatorHelper 公开方法与 UI 调用关系体现。

Sources

(3 files)
src/BD.WTTS.Client.Plugins.Authenticator/Entities
src/BD.WTTS.Client.Plugins.Authenticator/Helpers
src/BD.WTTS.Client.Plugins.Authenticator/Repositories