HarmonyOS API 26 UKey PIN 授权门禁:状态查询、失败归因与资源闭环
外接 UKey 的签名流程里,最危险的不是“PIN 输入框没弹出来”,而是页面把“用户刚刚验证过”当成一张长期通行证。资源被关闭、操作被取消、扩展返回错误,甚至另一个页面切换了证书后,如果应用仍拿旧状态继续签名,就会把交互状态和真实密钥资源错配。
本文围绕一条可审计的应用侧路径展开:用系统证书选择流程取得资源标识;打开资源;在每次高权限操作前查询 PIN 状态;只有明确处于认证成功态才放行;操作结束无论成功失败都关闭资源;异常发生时保留平台主错误码,并在 API 26 可用的条件下立刻读取扩展错误信息。文中代码分为两类:标明“API 示例”的部分对应官方接口形态;标明“应用策略”的部分是可单测的业务边界,不代表 HarmonyOS 编译或设备联调已完成。
先把资源、认证和签名结果拆开
实际排查时,三种状态经常被揉成一个isAuthorized布尔值,后来很难知道失败在资源打开、PIN 认证,还是签名操作本身。至少要保留三份互不替代的证据:resourceState表示当前资源是否由本轮流程打开;pinState是系统/扩展针对该资源返回的认证状态;operationResult才表示签名动作的业务结果。
| 观察到的状态 | 可以做什么 | 不能据此推断什么 |
|---|---|---|
| 资源未打开 | 展示重试或重新选择入口 | PIN 已认证 |
| 资源已打开,PIN 未认证 | 引导进入受控认证流程 | 私钥可以签名 |
| PIN 已认证 | 放行当前资源上的授权动作 | 其他资源或未来会话也已授权 |
| 签名返回成功 | 保存本次操作结果 | 用户身份或业务载荷无需再核验 |
关键是把授权结论限定在“当前 resourceId、当前资源会话、当前待执行动作”上。不要将上一次签名成功写成全局状态,也不要把客户端页面变量当成密钥管理服务的权威认证状态。
资源标识必须来自受支持的获取路径
官方说明将resourceId用作外部密钥资源标识:证书选择场景可由证书管理系统的授权对话框返回;API 26 起,密钥生成或导入等不需要证书选择的场景可使用getResourceId。打开资源后再执行密钥操作,结束后关闭资源。不要在业务代码里自行拼一个“看起来像 JSON”的标识去替代系统返回值。
推荐把系统返回的标识包成短生命周期句柄,界面层只保存引用,不把完整资源标识写进埋点或普通日志。页面返回、用户切换证书、窗口关闭、应用流程取消,都应让当前句柄失效。
interfaceSelectedKeyResource{resourceId:string;selectionId:string;selectedAt:number;}functionisCurrentSelection(selected:SelectedKeyResource,activeSelectionId:string,now:number):boolean{returnselected.resourceId.length>0&&selected.selectionId===activeSelectionId&&now>=selected.selectedAt;}这里的selectionId和时间字段是应用自己的防串用上下文,并非 HUKS 参数。它们能阻止 UI 延迟回调把旧证书的状态误用于新选择,但不能代替平台对资源与 PIN 状态的校验。
每次高权限动作前查询 PIN 状态
官方 ArkTS 指南说明,从 API 22 开始,huksExternalCrypto.getUkeyPinAuthState可查询 PIN 认证状态;查询所需resourceId应来自证书管理系统的授权流程。API 返回枚举值,而非应用自定义缓存。下面保留官方接口的关键调用形式,外围错误类型映射属于应用层包装,需按目标 SDK 声明补齐。
import{huksExternalCrypto}from'@kit.UniversalKeystoreKit';asyncfunctionqueryUkeyPinState(resourceId:string):Promise<huksExternalCrypto.HuksExternalPinAuthState>{constextProperties:Array<huksExternalCrypto.HuksExternalCryptoParam>=[];returnawaithuksExternalCrypto.getUkeyPinAuthState(resourceId,extProperties);}不要只在页面加载时查询一次,然后在页面存活期间无限复用。资源关闭会清理关联 PIN 认证状态与会话;因此每次签名动作应在同一资源上下文内重新查询,认证结果不等于跨页面、跨资源的授权缓存。
让“未知”与“未认证”都走拒绝路径
门禁逻辑要遵循 fail closed:明确成功才能继续,未认证则进入认证步骤;查询异常、枚举值未知、资源不匹配、选择上下文过期都不能转成成功。下面的策略函数只处理纯数据,适合脱离设备环境做单元测试。
typePinGateState='authenticated'|'not-authenticated'|'unknown';typeGateDecision='allow-current-action'|'request-auth'|'deny-and-reopen';functiondecidePinGate(resourceMatches:boolean,state:PinGateState):GateDecision{if(!resourceMatches||state==='unknown')return'deny-and-reopen';if(state==='authenticated')return'allow-current-action';return'request-auth';}真实业务还要把返回值映射到明确的 UI 状态:request-auth不能自动重放 PIN,也不能绕过用户确认;deny-and-reopen应提示重新选择或打开资源,并清除页面中的旧授权提示。对付款、签署协议等业务操作,授权成功只是技术门槛,业务还需再次确认待签数据的摘要和接收方。
资源关闭是认证状态边界,不是可选清理
官方资源管理说明要求先打开资源再执行后续密钥操作,完成后关闭资源;关闭时扩展会清理该资源关联的 PIN 认证状态和会话句柄。因而应把资源生命周期收进单一 service,而不是由多个页面分别负责“记得关闭”。
interfaceResourceLifecyclePort<T>{open(resourceId:string):Promise<T>;close(resourceId:string):Promise<void>;}asyncfunctionwithUkeyResource<T,R>(port:ResourceLifecyclePort<T>,resourceId:string,action:(handle:T)=>Promise<R>,reportCleanupFailure:(error:unknown)=>void):Promise<R>{consthandle=awaitport.open(resourceId);letactionFailed=false;try{returnawaitaction(handle);}catch(error){actionFailed=true;throwerror;}finally{try{awaitport.close(resourceId);}catch(closeError){reportCleanupFailure(closeError);if(!actionFailed)throwcloseError;}}}这是生命周期包装的应用侧示意,open/close的实际适配应调用目标 API 对应的 HUKS 接口,并转换平台错误。回调只接收脱敏后的清理故障分类;有主操作异常时保留主错误,单独记录关闭失败;主操作成功但关闭失败时则将清理故障返回给调用者。若产品要重试,先重新打开资源并重新查询 PIN 状态,不复用已关闭资源的认证结论。
API 26 错误归因要紧跟失败点
从 API 26 开始,huksExternalCrypto.getErrorInfo()可读取最近一次外部密钥管理扩展操作返回的详细信息。它只补充扩展侧错误;HUKS 自身错误仍通过接口异常抛出。官方还特别说明,扩展未提供详细错误时,errno可能为 0 或保留上次值、描述可能为空,因此调用方必须保留当前接口抛出的主错误码,不能把旧的扩展错误误贴到新故障上。
interfaceFailureEvidence{operation:'open'|'pin-state'|'authenticate'|'sign'|'close';platformCode:number|null;extensionCode:number|null;extensionDescriptionPresent:boolean;}functionbuildFailureEvidence(operation:FailureEvidence['operation'],platformCode:number|null,extensionCode:number,extensionDescription:string):FailureEvidence{return{operation,platformCode,extensionCode:extensionCode===0?null:extensionCode,extensionDescriptionPresent:extensionDescription.length>0};}在catch分支中应立即读取扩展信息并复制必要字段,再异步写审计队列。只记录错误阶段、稳定的业务 requestId、平台错误码、非零扩展码和描述是否存在;不要记录 PIN 原文、密钥材料、完整证书内容或未经清洗的扩展文本。扩展描述可以含供应商或设备细节,应受控保存并限制访问。
串行化同一资源的认证与签名操作
双击“签名”、页面自动重试和后台恢复可能同时触发同一资源的open → PIN状态查询 → sign → close。如果两个任务交错,A 任务关闭资源后,B 任务仍以旧状态继续,UI 却可能展示“验证成功”。在应用层为同一resourceId建立互斥队列或操作租约,并且每个动作绑定唯一 requestId。
interfaceSignIntent{requestId:string;selectionId:string;payloadDigest:string;}functioncanContinueIntent(intent:SignIntent,activeSelectionId:string,finishedRequestIds:Set<string>):boolean{returnintent.requestId.length>0&&intent.selectionId===activeSelectionId&&!finishedRequestIds.has(intent.requestId);}这个纯逻辑检查只防止本地重复触发或旧页面回调;真正的签名仍必须由资源 service 在打开资源后重新查 PIN 状态,再调用对应平台操作。对于用户取消的动作,requestId 状态应进入cancelled而不是failed,恢复时不能静默重播;新一次用户确认应产生新 requestId。
PIN 与错误文本按敏感数据处理
PIN 是用户的认证秘密,不应写入偏好设置、数据库、崩溃日志、埋点参数或本地明文调试文件。输入组件提交后尽可能缩短字符串持有时间;取消或页面退出时丢弃草稿。不要在应用中自行实现连续错误次数、解锁策略或硬件资源访问控制;应遵守 UKey/扩展提供的认证行为,不绕过认证界面。
官方 UKey PIN 说明将“硬件设备 + PIN”作为双因素访问保护,并指出 PIN 校验在 UKey 设备内完成。应用负责的是最小化留存、透明告知和安全调用,不应承诺自己可以读取私钥,也不应把“查询到已认证”包装成对用户身份的普遍证明。日志字段用分类值和不可逆业务关联标识,避免原始凭证及完整 resourceId 暴露。
用状态矩阵验证门禁与清理顺序
可先对纯策略模块做单测,再在目标 API 和实际扩展环境验证平台行为。以下矩阵重点验证边界条件,而不是只跑一次成功路径。
| 场景 | 期望 UI / service 行为 | 通过证据 |
|---|---|---|
| 资源打开失败 | 不查询 PIN、不签名,返回可重试错误 | open失败事件与 requestId |
| PIN 未认证 | 保持操作待确认,进入受支持的认证流程 | 查询结果为未认证,不产生签名调用 |
| PIN 查询异常 | fail closed,提示重开资源 | 当前平台码被保留,未调用签名 |
| 资源选择被替换 | 旧 selectionId 的结果丢弃 | 旧回调不更新新页面状态 |
| 签名成功或失败 | 均走资源关闭;关闭错误单独记录 | 生命周期顺序与两个错误槽位 |
| 同资源快速双击 | 一个操作执行,重复请求被拒绝或合并 | requestId 去重及调用次数 |
| API 26 扩展错误为空 | 主错误仍可定位,不沿用旧扩展描述 | platformCode 有值,extensionCode 为空 |
| 用户主动取消 | 不自动重试、不继续签名 | 状态为 cancelled,未发生副作用 |
本地纯逻辑测试边界
下面的测试只验证门禁决策、资源上下文隔离、重复请求和错误证据组装,不调用 HUKS、不替代 HarmonyOS 编译,也不代表真机 UKey 验收。
常见故障先看哪一层
| 现象 | 优先检查 | 建议动作 |
|---|---|---|
| 状态查询成功但签名被拒 | 是否为同一 resourceId、资源是否被关闭重开 | 重新打开资源并在动作前重新查询 |
| PIN 失败但扩展码看起来是旧值 | 是否在当前失败后立即获取getErrorInfo | 以当前异常主码为主,扩展码只作补充 |
| 用户取消后又触发签名 | Promise 拒绝是否被当作普通错误自动重试 | 将取消建模为终态,要求新的显式确认 |
| 页面显示授权通过但资源已切换 | 旧请求是否携带 selectionId | 丢弃旧结果,清除旧授权态 |
| 连续签名时偶发资源不可用 | 是否有并发 open/close 交错 | 按 resourceId 串行执行生命周期 |
| 日志泄露认证信息 | 是否序列化了 PIN、完整资源标识或错误原文 | 采用白名单字段和敏感值脱敏 |
官方指南可用于核对具体接口和边界:PIN 认证状态查询(ArkTS)、UKey PIN 认证介绍与规格、API 26 获取外部密钥扩展错误信息、外部密钥资源管理说明。涉及 API 接入时,以项目使用的 SDK 声明和设备/扩展支持范围为准。
收口:把认证判断压在动作边界
可靠的 UKey 流程不是“登录时验一次 PIN”,而是把资源打开、认证状态查询、敏感动作、错误归因和资源关闭串成一个短事务。交付前可按下面五项逐条复核:
- PIN 状态只对当前资源、当前会话和当前动作有效,状态查询失败一律不签名。
- 用户取消、换证书或页面退出会让旧操作失效,不自动提交旧 intent。
- API 26 扩展错误在失败点立即读取,接口主错误码始终单独保留。
openResource成功后的每条路径都能走到资源关闭;清理错误不会吞掉主操作错误。- PIN、私钥材料、完整证书和原始资源标识不进入普通日志、埋点或持久化草稿。
这样做的收益不是让失败消失,而是让每次拒绝都可解释、每份认证状态都有明确边界、每次资源使用都能收尾。若目标设备、驱动扩展或 SDK 版本不满足对应能力,应在能力探测和产品提示层显式降级,不用“本地状态看起来已认证”绕过服务端门禁。