HarmonyOS API 26 UKey PIN 授权门禁:状态查询、失败归因与资源闭环

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"的标识去替代系统返回值。

推荐把系统返回的标识包成短生命周期句柄,界面层只保存引用,不把完整资源标识写进埋点或普通日志。页面返回、用户切换证书、窗口关闭、应用流程取消,都应让当前句柄失效。

ts 复制代码
interface SelectedKeyResource {
  resourceId: string;
  selectionId: string;
  selectedAt: number;
}

function isCurrentSelection(
  selected: SelectedKeyResource,
  activeSelectionId: string,
  now: number
): boolean {
  return selected.resourceId.length > 0 &&
    selected.selectionId === activeSelectionId &&
    now >= selected.selectedAt;
}

这里的 selectionId 和时间字段是应用自己的防串用上下文,并非 HUKS 参数。它们能阻止 UI 延迟回调把旧证书的状态误用于新选择,但不能代替平台对资源与 PIN 状态的校验。

每次高权限动作前查询 PIN 状态

官方 ArkTS 指南说明,从 API 22 开始,huksExternalCrypto.getUkeyPinAuthState 可查询 PIN 认证状态;查询所需 resourceId 应来自证书管理系统的授权流程。API 返回枚举值,而非应用自定义缓存。下面保留官方接口的关键调用形式,外围错误类型映射属于应用层包装,需按目标 SDK 声明补齐。

ts 复制代码
import { huksExternalCrypto } from '@kit.UniversalKeystoreKit';

async function queryUkeyPinState(
  resourceId: string
): Promise<huksExternalCrypto.HuksExternalPinAuthState> {
  const extProperties: Array<huksExternalCrypto.HuksExternalCryptoParam> = [];
  return await huksExternalCrypto.getUkeyPinAuthState(resourceId, extProperties);
}

不要只在页面加载时查询一次,然后在页面存活期间无限复用。资源关闭会清理关联 PIN 认证状态与会话;因此每次签名动作应在同一资源上下文内重新查询,认证结果不等于跨页面、跨资源的授权缓存。

让"未知"与"未认证"都走拒绝路径

门禁逻辑要遵循 fail closed:明确成功才能继续,未认证则进入认证步骤;查询异常、枚举值未知、资源不匹配、选择上下文过期都不能转成成功。下面的策略函数只处理纯数据,适合脱离设备环境做单元测试。

ts 复制代码
type PinGateState = 'authenticated' | 'not-authenticated' | 'unknown';
type GateDecision = 'allow-current-action' | 'request-auth' | 'deny-and-reopen';

function decidePinGate(
  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,而不是由多个页面分别负责"记得关闭"。

ts 复制代码
interface ResourceLifecyclePort<T> {
  open(resourceId: string): Promise<T>;
  close(resourceId: string): Promise<void>;
}

async function withUkeyResource<T, R>(
  port: ResourceLifecyclePort<T>,
  resourceId: string,
  action: (handle: T) => Promise<R>,
  reportCleanupFailure: (error: unknown) => void
): Promise<R> {
  const handle = await port.open(resourceId);
  let actionFailed = false;
  try {
    return await action(handle);
  } catch (error) {
    actionFailed = true;
    throw error;
  } finally {
    try {
      await port.close(resourceId);
    } catch (closeError) {
      reportCleanupFailure(closeError);
      if (!actionFailed) throw closeError;
    }
  }
}

这是生命周期包装的应用侧示意,open/close 的实际适配应调用目标 API 对应的 HUKS 接口,并转换平台错误。回调只接收脱敏后的清理故障分类;有主操作异常时保留主错误,单独记录关闭失败;主操作成功但关闭失败时则将清理故障返回给调用者。若产品要重试,先重新打开资源并重新查询 PIN 状态,不复用已关闭资源的认证结论。

API 26 错误归因要紧跟失败点

从 API 26 开始,huksExternalCrypto.getErrorInfo() 可读取最近一次外部密钥管理扩展操作返回的详细信息。它只补充扩展侧错误;HUKS 自身错误仍通过接口异常抛出。官方还特别说明,扩展未提供详细错误时,errno 可能为 0 或保留上次值、描述可能为空,因此调用方必须保留当前接口抛出的主错误码,不能把旧的扩展错误误贴到新故障上。

ts 复制代码
interface FailureEvidence {
  operation: 'open' | 'pin-state' | 'authenticate' | 'sign' | 'close';
  platformCode: number | null;
  extensionCode: number | null;
  extensionDescriptionPresent: boolean;
}

function buildFailureEvidence(
  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。

ts 复制代码
interface SignIntent {
  requestId: string;
  selectionId: string;
  payloadDigest: string;
}

function canContinueIntent(
  intent: SignIntent,
  activeSelectionId: string,
  finishedRequestIds: Set<string>
): boolean {
  return intent.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 版本不满足对应能力,应在能力探测和产品提示层显式降级,不用"本地状态看起来已认证"绕过服务端门禁。

相关推荐
Dream-Y.ocean6 小时前
鸿蒙PC平台星图遥感适配实战:基于 Electron 壳方案的纯 JavaScript 遥感图像处理引擎开发
javascript·electron·harmonyos
七月稻草人11 小时前
鸿蒙平板能当电脑第二块屏幕吗?ToDesk、向日葵镜像/副屏实测
华为·电脑·harmonyos
贾伟康12 小时前
【共创稿事节】HarmonyOS 7.0 Want隐式跳转:从接入到生产验证
性能优化·实战·harmonyos·arkts·arkui
NJPJI_Yang12 小时前
鸿蒙 HarmonyOS 7 应用内存优化:appMemoryOptimizer 释放 so 页缓存实战
pytorch·深度学习·harmonyos
翰德恩咨询13 小时前
IPD咨询洞察:华为如何以项目为中心,激活组织
华为
熊猫钓鱼>_>13 小时前
三方库 NDK 原生模块跨平台适配与调试【鸿蒙心迹】
开发语言·华为·ai编程·harmonyos·鸿蒙·openharmony·鸿蒙心迹
行者-全栈开发1 天前
【鸿蒙心迹】三方库移植(二)——CMake 交叉编译与符号冲突排查实录(HarmonyOS 7.x)
harmonyos·cmake·交叉编译·三方库移植·ohos·符号冲突
梦想不只是梦与想1 天前
鸿蒙三方库源码调试
harmonyos·断点调试·三方源码调试
用户117910488331 天前
AI输出满屏井号星号?hmharness把Markdown渲染成终端表格 GitHub:swsgbl/hmharness
ai编程·harmonyos