引言
安全在鸿蒙应用里不是一个可以「最后再补」的模块。系统的权限模型、沙箱机制、数据分级是强约束,任何一环没对齐,表现都不是编译报错,而是运行时静默失败:权限声明了但没申请,接口返回 201;数据分级填低了,跨设备同步被拒绝;密钥存在文件里而不是 HUKS 里,用户换机后数据永久解不开。
真正的难点在于边界判断:哪些数据必须走硬件密钥库、哪些权限可以申请、哪些能力必须走 ACL 白名单。这些问题的答案不在 API 文档里,而在系统的安全模型里。理解模型之后再选 API,才不会出现「用对了接口但用错了地方」。
本文按「权限、沙箱、密钥、校验」四条线展开。签名与上架流程在 鸿蒙应用上架与签名打包 里已经讲过,本篇聚焦运行时的应用内安全。网络层的权限声明可以配合 鸿蒙网络请求与数据持久化 一起看。
目录
- 鸿蒙安全体系的分层
- 权限模型:system_grant 与 user_grant
- 运行时权限申请的正确姿势
- 受限权限与 ACL 白名单
- 应用沙箱与目录隔离
- 数据分级 S1 到 S4
- HUKS 密钥管理基础
- HUKS 加解密实战
- 证书与签名校验
- 网络安全配置与证书锁定
- 代码混淆与加固
- 权衡取舍
- 常见坑清单
- 小结
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. 应用沙箱与目录隔离
每个鸿蒙应用运行在独立的沙箱里,有自己的文件系统视图。目录的语义必须记清楚,写错位置要么数据丢失,要么被安全扫描标记。
| 目录 | 获取方式 | 特点 | 用途 |
|---|---|---|---|
| filesDir | context.filesDir | 应用私有,持久 | 数据库、缓存、配置 |
| cacheDir | context.cacheDir | 应用私有,系统可清理 | 临时文件、缩略图 |
| tempDir | context.tempDir | 应用私有,进程退出即清 | 单次会话临时数据 |
| preferencesDir | context.preferencesDir | 应用私有 | Preferences 落盘位置 |
| distributedFilesDir | context.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 密钥丢失、证书轮换、校验误判都是会真实发生的事,如果每个机制都是「失败即不可用」,用户会直接卸载应用。为每个安全机制设计一条降级路径,比把单点做得更强更重要。
常见坑清单
- user_grant 权限只声明不申请。 接口返回 201,排查方向容易误判为签名问题。
reason或usedScene缺失。 编译通过但上架审核驳回,且提示指向权限配置。- 重复申请已授权的权限。 第二次调用直接失败,误判为「权限被系统回收」。
- 用户拒绝后立刻再次弹窗。 触发系统限流,后续调用不弹窗直接返回拒绝。
- ACL 权限只写在 module.json5。 真正的白名单在签名 Profile 的 allowed-acls 里,漏配表现为权限未生效。
- 手写沙箱绝对路径。 不同 API 版本路径可能变化,或在沙箱校验时被拒绝。
- 数据分级填得过低。 S3 数据标成 S1 会被安全扫描标记,涉及用户信息时属于高危。
- 数据分级填得过高。 跨设备同步被系统拒绝,表现为「迁移后数据丢失」。
- GCM 模式复用 nonce。 直接破坏加密安全性,且不会有任何运行时错误。
- HUKS 会话未 finish 或 abort。 会话句柄泄漏,达到上限后所有密钥操作失败。
- 加密没有降级路径。 密钥失效后数据永久解不开,用户只能卸载重装。
- 证书锁定只锁一张证书。 证书轮换当天旧版本应用全部无法联网。
小结
鸿蒙应用安全可以压缩成四句话:权限侧先分清 system_grant 与 user_grant,前者声明即可、后者必须申请且要带 reason 与 usedScene,受限权限还要走 ACL 白名单;沙箱侧所有路径从 context 取、数据按真实敏感度分级;密钥侧能用 HUKS 就不要自己管密钥,同时为密钥失效准备降级路径;传输侧在 HTTPS 之上按业务敏感度决定是否做证书锁定,并记住锁定会绑定证书生命周期。
最后一条工程建议:把安全机制当成产品能力来设计,而不是当成补丁来打。每一个「失败即不可用」的点都要配一条降级路径,每一个「防住了」的结论都要能被安全扫描复现。想继续了解打包与签名链路的细节,可以回看 鸿蒙应用上架与签名打包 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。