鸿蒙应用安全:权限模型与 HUKS 密钥管理

本文系统梳理 HarmonyOS NEXT 的应用安全实践:system_grant 与 user_grant 两类权限的差异、受限权限 ACL 的申请流程、应用沙箱与数据分级、HUKS 密钥生成与加解密用法、证书与签名校验、网络安全配置与证书锁定,以及代码混淆与加固的取舍。

引言

安全在鸿蒙应用里不是一个可以「最后再补」的模块。系统的权限模型、沙箱机制、数据分级是强约束,任何一环没对齐,表现都不是编译报错,而是运行时静默失败:权限声明了但没申请,接口返回 201;数据分级填低了,跨设备同步被拒绝;密钥存在文件里而不是 HUKS 里,用户换机后数据永久解不开。

真正的难点在于边界判断:哪些数据必须走硬件密钥库、哪些权限可以申请、哪些能力必须走 ACL 白名单。这些问题的答案不在 API 文档里,而在系统的安全模型里。理解模型之后再选 API,才不会出现「用对了接口但用错了地方」。

本文按「权限、沙箱、密钥、校验」四条线展开。签名与上架流程在 鸿蒙应用上架与签名打包 里已经讲过,本篇聚焦运行时的应用内安全。网络层的权限声明可以配合 鸿蒙网络请求与数据持久化 一起看。

目录

  1. 鸿蒙安全体系的分层
  2. 权限模型:system_grant 与 user_grant
  3. 运行时权限申请的正确姿势
  4. 受限权限与 ACL 白名单
  5. 应用沙箱与目录隔离
  6. 数据分级 S1 到 S4
  7. HUKS 密钥管理基础
  8. HUKS 加解密实战
  9. 证书与签名校验
  10. 网络安全配置与证书锁定
  11. 代码混淆与加固
  12. 权衡取舍
  13. 常见坑清单
  14. 小结

1. 鸿蒙安全体系的分层

鸿蒙的安全能力从下到上分成四层,每层解决不同的问题,混用会导致「防住了 A 但漏了 B」。

层次机制解决的问题开发者需要做的
应用层权限模型、数据分级能力访问与数据流转声明权限、申请授权、正确分级
隔离层应用沙箱、目录隔离进程间数据不可见只在自己的目录下读写
密钥层HUKS 硬件密钥库密钥不被导出、不落明文用 HUKS 生成与使用密钥
传输层证书校验、证书锁定通信不被中间人劫持配置网络安全、做证书校验

分层的意义在于纵深防御:即使某一层被绕过,下一层仍然能兜住。例如攻击者拿到了沙箱内的文件,如果密钥存在 HUKS 里,文件内容仍然是密文;即使密钥被导出(HUKS 保证不会),还有证书锁定防止数据被发到伪造服务器。

实践中常见的错误是只做一层:只声明权限不做沙箱隔离,或者只做 HTTPS 不做证书锁定。安全评估工具会逐层检查,缺哪一层都会被标记。

2. 权限模型:system_grant 与 user_grant

鸿蒙的权限按授权方式分成两类,这个分类决定了「声明就够」还是「必须弹窗」。

授权模式含义是否需要运行时申请典型权限
system_grant系统自动授予否,声明即可用ohos.permission.INTERNET、GET_NETWORK_INFO
user_grant需用户明确同意是,必须弹窗CAMERA、LOCATION、READ_MEDIA、DISTRIBUTED_DATASYNC

判断某个权限属于哪一类,看它的 grantMode 字段。最容易踩的坑是默认「所有权限都是 system_grant」:在 module.json5 里声明了 ohos.permission.CAMERA 就直接调相机接口,结果拿到 201,排查半天才发现是没申请。

{
  "module": {
    "name": "entry",
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET"
      },
      {
        "name": "ohos.permission.CAMERA",
        "reason": "$string:reason_camera",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      }
    ]
  }
}

reason 与 usedScene 是 user_grant 权限的必填项,缺少会在上架审核时被驳回。reason 必须通过 $string: 引用资源,写死中文字符串虽然能编译,但审核要求可国际化;usedScene.when 取 inuse(仅前台)或 always(后台也要),后者审核更严格,能用 inuse 就不要写 always。

在代码里判断某个权限属于哪一类,最可靠的方式不是记表格,而是直接读权限定义:

import { abilityAccessCtrl } from '@kit.AbilityKit';

// 通过 tokenId 查询授权状态:已授权返回 0,未授权返回 -1
const atManager = abilityAccessCtrl.createAtManager();
const status = await atManager.checkAccessToken(tokenId, 'ohos.permission.CAMERA');
console.info(`grant status: ${status}`);

对 system_grant 权限,checkAccessToken 在声明之后会直接返回「已授权」;对 user_grant 权限,它会返回「未授权」,直到用户同意为止。用这一个接口就能把两类权限区分开,比维护一张权限清单更可靠,因为权限清单会随 API 版本变化。

3. 运行时权限申请的正确姿势

user_grant 权限的申请流程分两步:先查当前授权状态,再按需申请。

import { abilityAccessCtrl, common, Permissions } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

export class PermissionHelper {
  private static readonly TARGET: Permissions[] = [
    'ohos.permission.CAMERA',
    'ohos.permission.READ_MEDIA'
  ];

  static async ensure(context: common.UIAbilityContext): Promise<boolean> {
    const atManager = abilityAccessCtrl.createAtManager();
    const tokenId = context.applicationInfo.accessTokenId;

    // 第一步:查询已授权状态,避免重复弹窗
    const status = await atManager.checkAccessToken(tokenId, 'ohos.permission.CAMERA');
    if (status === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
      return true;
    }

    // 第二步:只申请未授权的
    try {
      const result = await atManager.requestPermissionsFromUser(context, PermissionHelper.TARGET);
      // authResults 与 TARGET 顺序一一对应,0 表示已授权
      const denied = result.authResults.filter((code: number) => code !== 0);
      if (denied.length > 0) {
        console.warn(`denied count: ${denied.length}`);
      }
      return denied.length === 0;
    } catch (err) {
      const e = err as BusinessError;
      console.error(`request failed: ${e.code}`);
      return false;
    }
  }
}

三条纪律必须遵守:其一,先查再申请,已授权的权限重复申请会直接返回失败;其二,authResults 与申请数组按下标一一对应,不要用 every 之外的方式猜测哪个被拒;其三,用户拒绝后不要立刻再弹,应该先展示用途说明,用户点「去授权」时再调申请接口。连续弹窗会被系统限流,短时间内第二次调用直接返回拒绝且不弹窗。

4. 受限权限与 ACL 白名单

有一类权限既不自动授予,也不允许普通应用申请,必须经过审核后写进 ACL(Access Control List)白名单才能使用。

权限类型申请方式审核要求示例
普通 system_grant声明即可无INTERNET、GET_NETWORK_INFO
普通 user_grant声明 + 运行时申请无CAMERA、LOCATION
受限权限(ACL)申请证书时附带 ACL 配置需要资质说明READ_WRITE_DOWNLOAD_DIRECTORY、MANAGE_MEDIA
系统权限仅系统应用可用不开放各类 system_core 权限

ACL 的申请流程在 AGC 侧提交,需要说明「为什么普通权限无法满足需求」。ACL 的配置在签名 Profile 里,而不是 module.json5 里——这一点非常反直觉:你在 module.json5 里正常声明权限,但真正决定它是否生效的是 Profile 中 acls 字段里的 allowed-acls 列表。

调试期可以用调试证书附带的 ACL 先跑通,但发布证书的 ACL 必须单独申请。调试能跑、发布包崩溃的典型原因就是漏了这一步,表现为申请权限时返回「权限未在 ACL 中声明」。

5. 应用沙箱与目录隔离

每个鸿蒙应用运行在独立的沙箱里,有自己的文件系统视图。目录的语义必须记清楚,写错位置要么数据丢失,要么被安全扫描标记。

目录获取方式特点用途
filesDircontext.filesDir应用私有,持久数据库、缓存、配置
cacheDircontext.cacheDir应用私有,系统可清理临时文件、缩略图
tempDircontext.tempDir应用私有,进程退出即清单次会话临时数据
preferencesDircontext.preferencesDir应用私有Preferences 落盘位置
distributedFilesDircontext.distributedFilesDir可跨设备同步分布式共享文件
公共目录需权限访问所有应用可见下载、相册(受 ACL 约束)

一条硬规则:除了公共目录,所有路径都必须从 context 获取,不要手写。手写 /data/storage/el2/base/... 这类绝对路径在不同 API 版本上可能失效,而且会在沙箱校验时被拒绝。

沙箱之外还有一个容易忽略的隔离维度:Preferences 与 RDB 按文件名隔离。同一个应用里不同模块用同名文件会互相覆盖,因此命名要有模块前缀。这不是安全机制,而是工程纪律,但它的故障表现(数据莫名被清)经常被误判为安全问题。

6. 数据分级 S1 到 S4

数据分级是鸿蒙特有的概念,它决定数据能否被跨设备同步、备份、以及在多用户场景下是否隔离。

分级含义跨设备同步典型数据
S1公开或低敏感允许商品列表、公开文章、主题设置
S2一般业务数据允许(需用户同意)阅读进度、收藏、搜索历史
S3个人敏感信息受限通讯录、聊天记录、位置轨迹
S4极敏感严格受限,通常禁止同步生物特征、支付凭证、密钥材料

分级的落点在两处:RDB 的 securityLevel 与 KVStore 的 securityLevel。分级填低了是安全问题,填高了是功能问题:把阅读进度标成 S3,跨设备同步会被系统拒绝,表现为「迁移后进度丢失」;把支付凭证标成 S1,会被安全扫描标记为高危。

一个务实的判断方法:问自己「这条数据泄露后,用户的损失是什么」。只是体验损失就是 S1/S2,涉及身份或财产就是 S3,涉及不可撤销的凭证就是 S4。密钥材料永远不要进 RDB,那是 HUKS 的职责。

分级的落点在建库与建 store 时一次性确定,之后无法修改:

import { relationalStore } from '@kit.ArkData';

const CONFIG: relationalStore.StoreConfig = {
  name: 'reading.db',
  // 阅读进度属于一般业务数据,S2 允许跨设备同步
  securityLevel: relationalStore.SecurityLevel.S2,
  encrypt: false
};

const store = await relationalStore.getRdbStore(context, CONFIG);

注意 securityLevel 与 encrypt 是两个独立维度:分级决定数据能否流转,encrypt 决定数据是否加密落盘。两者都需要判断,不要以为设了 S3 就自动加密了。需要加密落盘时把 encrypt 置为 true,密钥由系统托管,不需要应用自己管理。

7. HUKS 密钥管理基础

HUKS(HarmonyOS Universal KeyStore)是系统提供的密钥管理服务,核心价值是密钥在硬件安全环境中生成与使用,永远不会以明文形式出现在应用内存或文件系统里。

import { huks } from '@kit.UniversalKeystoreKit';

const KEY_ALIAS = 'com.example.reader.dbkey';

export async function generateKey(): Promise<void> {
  const options: huks.HuksOptions = {
    properties: [
      { tag: huks.HuksTag.HUKS_TAG_ALGORITHM, value: huks.HuksKeyAlg.HUKS_ALG_AES },
      { tag: huks.HuksTag.HUKS_TAG_KEY_SIZE, value: huks.HuksKeySize.HUKS_AES_KEY_SIZE_256 },
      { tag: huks.HuksTag.HUKS_TAG_PURPOSE,
        value: huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_ENCRYPT |
               huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_DECRYPT },
      { tag: huks.HuksTag.HUKS_TAG_PADDING, value: huks.HuksKeyPadding.HUKS_PADDING_NONE },
      { tag: huks.HuksTag.HUKS_TAG_BLOCK_MODE, value: huks.HuksCipherMode.HUKS_MODE_GCM }
    ]
  };

  const handle = await huks.generateKeyItem(KEY_ALIAS, options);
  console.info(`key generated: ${handle}`);
}

三个关键点:KEY_ALIAS 是应用内唯一的密钥标识,换机后不会自动迁移,它对应的是设备级的密钥材料;AES-GCM 必须配 HUKS_PADDING_NONE,写 PKCS7 会直接报参数错误;generateKeyItem 返回的 handle 只是句柄,密钥本身不可导出(除非生成时显式声明 HUKS_TAG_KEY_STORAGE_FLAG 允许导出,一般不要)。

HUKS 支持的算法覆盖 AES、RSA、ECC、HMAC、SM2/SM4(国密),签名与验签走 huks.initSession + huks.updateSession + huks.finishSession 三步式调用。HUKS 的调用必须成对:每次 initSession 都要配对 finishSession,中途失败要调 abortSession 释放会话,否则会话句柄泄漏,达到上限后所有密钥操作都会失败。

8. HUKS 加解密实战

把密钥生成与加解密串起来,做一个本地数据库加密的完整链路。

import { huks } from '@kit.UniversalKeystoreKit';

export async function encrypt(plain: Uint8Array): Promise<Uint8Array> {
  const nonce = new Uint8Array(12);
  cryptoFramework.createRandom().generateRandomSync(nonce);

  const options: huks.HuksOptions = {
    properties: [
      { tag: huks.HuksTag.HUKS_TAG_ALGORITHM, value: huks.HuksKeyAlg.HUKS_ALG_AES },
      { tag: huks.HuksTag.HUKS_TAG_PURPOSE, value: huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_ENCRYPT },
      { tag: huks.HuksTag.HUKS_TAG_BLOCK_MODE, value: huks.HuksCipherMode.HUKS_MODE_GCM },
      { tag: huks.HuksTag.HUKS_TAG_PADDING, value: huks.HuksKeyPadding.HUKS_PADDING_NONE },
      { tag: huks.HuksTag.HUKS_TAG_NONCE, value: nonce }
    ],
    inData: plain
  };

  const handle = await huks.initSession(KEY_ALIAS, options);
  try {
    const result = await huks.finishSession(handle.handle, options);
    // 返回的 outData 里含密文与 GCM tag,nonce 必须单独存下来
    return result.outData;
  } catch (err) {
    await huks.abortSession(handle.handle, options);
    throw new Error('encrypt failed');
  }
}

GCM 模式下 nonce 必须每次加密都重新生成,且不能重复使用。重复 nonce 会直接破坏 GCM 的安全性,攻击者可以恢复出明文。nonce 不是秘密,可以跟密文一起存,但绝不能硬编码成固定值。

另一个必须处理的细节是密钥失效:用户清除应用数据、系统重置、或者密钥被系统回收后,initSession 会返回 HUKS_ERROR_KEY_NOT_FOUND。此时应用必须能优雅降级——要么提示用户重新登录并重建密钥,要么用账号密码派生的密钥重新解密云端备份。没有降级路径的加密等于给用户埋了一个永久解不开的锁。

9. 证书与签名校验

应用自身完整性校验是防二次打包的手段,核心是比对运行时签名与预期签名。

import { bundleManager } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

export async function verifySignature(context: Context): Promise<boolean> {
  try {
    const info = await bundleManager.getBundleInfoForSelf(
      bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_SIGNATURE_INFO);
    const fingerprints = info.signatureInfo?.fingerprint;
    // 与打包时记录的证书指纹比对
    const EXPECTED = 'A1:B2:C3:...';
    return fingerprints === EXPECTED;
  } catch (err) {
    const e = err as BusinessError;
    console.error(`verify failed: ${e.code}`);
    return false;
  }
}

这里的 fingerprint 是签名证书的指纹。校验失败时的处理策略比校验本身更重要:直接退出应用体验太粗暴,更适合的做法是降级——禁用需要保护的功能(支付、导出),或者把异常上报到风控系统后继续运行,避免被逆向者通过「改掉校验点」轻易绕过。

需要说明的是,签名校验只能防住「简单重打包」,无法防住有能力的逆向者。它属于提高门槛的手段,不是安全边界。真正需要保护的逻辑应该放在服务端,或者用 HUKS 绑定的密钥做能力解锁。

10. 网络安全配置与证书锁定

HTTPS 只保证传输加密,不保证对端可信——如果攻击者能让设备信任一个伪造的根证书(例如用户安装了恶意 CA),中间人就能解密全部流量。证书锁定(Certificate Pinning)解决这个问题。

{
  "network-security-config": {
    "base-config": {
      "cleartextTrafficPermitted": false,
      "trust-anchors": [
        { "certificates": "$profile:server_cert" }
      ]
    },
    "domain-config": [
      {
        "cleartextTrafficPermitted": false,
        "domains": [{ "include-subdomains": true, "name": "api.example.com" }],
        "trust-anchors": [{ "certificates": "$profile:server_cert" }]
      }
    ]
  }
}

这段配置写在 resources/base/profile/network_config.json,并在 module.json5 中通过 metadata 关联。它的作用有三:cleartextTrafficPermitted: false 禁止明文 HTTP(防止误用 http 地址),trust-anchors 限定只信任指定的证书(即锁定),domain-config 可以针对单个域名做更严格的策略。

锁定的代价是证书轮换会直接导致旧版本应用无法联网。正确的做法是同时锁定当前证书与下一张备用证书,并在服务端保留灰度期,让旧版本有升级窗口。如果只有一个域名且证书一年一换,这条风险必须写进发布流程。

11. 代码混淆与加固

混淆的作用是提高逆向成本,它不是加密,也不能替代上面任何一层。鸿蒙的混淆通过 build-profile.json5 的 arkOptions.obfuscationRule 配置,可以按文件粒度控制哪些标识符参与混淆。

手段收益代价建议
标识符混淆显著提高反编译可读性崩溃栈需要符号表还原发布包必开
字符串加密隐藏接口地址与提示文案少量运行时开销涉及敏感地址时开
资源混淆减小体积、隐藏资源名调试期不便视情况
三方加固服务防调试、防注入影响启动性能与稳定性高风险业务评估后使用

混淆的配置与符号表上传流程在 鸿蒙应用上架与签名打包 里有完整说明。这里只强调两条安全侧的纪律:密钥与地址不要硬编码在 ArkTS 里,混淆挡不住有心人,敏感值应该从服务端下发或存进 HUKS;混淆后必须做一轮完整回归,反射与动态属性名相关的代码(例如依赖属性名字符串的序列化)是混淆最容易打断的地方。

混淆规则通过 build-profile.json5 的 arkOptions 声明,粒度可以细到单个文件:

{
  "buildOption": {
    "arkOptions": {
      "obfuscation": {
        "ruleOptions": {
          "enable": true,
          "files": ["./obfuscation-rules.txt"]
        },
        "consumerFiles": ["./consumer-rules.txt"]
      }
    }
  }
}

files 是本模块的混淆规则,consumerFiles 是供依赖方使用的保留规则(例如 HAR 包对外暴露的类名不能被混淆)。HAR 与 HSP 包必须提供 consumer-rules.txt,否则使用方开启混淆后调用你的接口会因为类名被改而失败,而且这类问题只在发布包出现,调试包永远正常。

权衡取舍

安全措施的取舍本质是「防护强度」与「可用性、性能、维护成本」的平衡。

决策点强防护方案轻量方案建议
本地密钥HUKS 硬件密钥账号密码派生密钥有硬件能力就用 HUKS,并保留派生密钥作为降级
敏感数据存储全库加密仅加密敏感字段数据量小用前者,量大用后者
证书锁定锁定 + 备用证书仅依赖系统 CA金融与支付必须锁定,普通业务可不做
完整性校验启动即校验并阻断校验后上报不阻断优先上报,避免误伤正常用户
混淆强度全量混淆 + 字符串加密仅标识符混淆发布包用前者,但必须完成回归

一条常被忽略的原则:安全机制必须可降级。HUKS 密钥丢失、证书轮换、校验误判都是会真实发生的事,如果每个机制都是「失败即不可用」,用户会直接卸载应用。为每个安全机制设计一条降级路径,比把单点做得更强更重要。

常见坑清单

  1. user_grant 权限只声明不申请。 接口返回 201,排查方向容易误判为签名问题。
  2. reason 或 usedScene 缺失。 编译通过但上架审核驳回,且提示指向权限配置。
  3. 重复申请已授权的权限。 第二次调用直接失败,误判为「权限被系统回收」。
  4. 用户拒绝后立刻再次弹窗。 触发系统限流,后续调用不弹窗直接返回拒绝。
  5. ACL 权限只写在 module.json5。 真正的白名单在签名 Profile 的 allowed-acls 里,漏配表现为权限未生效。
  6. 手写沙箱绝对路径。 不同 API 版本路径可能变化,或在沙箱校验时被拒绝。
  7. 数据分级填得过低。 S3 数据标成 S1 会被安全扫描标记,涉及用户信息时属于高危。
  8. 数据分级填得过高。 跨设备同步被系统拒绝,表现为「迁移后数据丢失」。
  9. GCM 模式复用 nonce。 直接破坏加密安全性,且不会有任何运行时错误。
  10. HUKS 会话未 finish 或 abort。 会话句柄泄漏,达到上限后所有密钥操作失败。
  11. 加密没有降级路径。 密钥失效后数据永久解不开,用户只能卸载重装。
  12. 证书锁定只锁一张证书。 证书轮换当天旧版本应用全部无法联网。

小结

鸿蒙应用安全可以压缩成四句话:权限侧先分清 system_grant 与 user_grant,前者声明即可、后者必须申请且要带 reason 与 usedScene,受限权限还要走 ACL 白名单;沙箱侧所有路径从 context 取、数据按真实敏感度分级;密钥侧能用 HUKS 就不要自己管密钥,同时为密钥失效准备降级路径;传输侧在 HTTPS 之上按业务敏感度决定是否做证书锁定,并记住锁定会绑定证书生命周期。

最后一条工程建议:把安全机制当成产品能力来设计,而不是当成补丁来打。每一个「失败即不可用」的点都要配一条降级路径,每一个「防住了」的结论都要能被安全扫描复现。想继续了解打包与签名链路的细节,可以回看 鸿蒙应用上架与签名打包 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「鸿蒙开发」更多文章

  1. 鸿蒙 ohpm 包管理与 Hypium 测试框架
  2. ArkUI 动画体系与手势交互
  3. 鸿蒙分布式软总线与跨设备迁移