鸿蒙百篇系列第 79 篇 · 专家
第 12 篇讲过安全机制与权限管理,第 33 篇讲过安全加固。这篇站在安全审计的角度,系统梳理鸿蒙应用在上线前需要做的安全检查和合规对照。
安全不是加一个混淆就完了的事。HarmonyOS NEXT 的星盾安全架构从系统层提供了能力,但开发者如果不知道该用哪些、怎么用,这些能力等于没有。这篇文章是一份审计清单,帮你逐项对照。
一、星盾安全架构:开发者需要知道的部分
HarmonyOS NEXT 的星盾安全架构包含四个层面:
星盾架构中,系统层做的事已经很多了。但开发者的责任不变:你申请了什么权限、怎么存敏感数据、第三方 SDK 是否安全——这些系统不管,出了事是你的。
二、权限审计清单
HarmonyOS NEXT 禁止的 9 类权限
以下权限在 HarmonyOS NEXT 中被禁止,如果你的代码里还有调用,必须移除:
权限申请原则
import abilityAccessCtrl from '@ohos.abilityAccessCtrl'
class PermissionManager {
private accessManager: abilityAccessCtrl.AccessManager =
abilityAccessCtrl.createAccessManager()
// 只在真正需要时申请,不要启动就批量申请
async requestLocationForMap(): Promise<boolean> {
const token = this.getContext().accessToken
try {
const result = await this.accessManager.requestPermissionsFromUser(
token,
['ohos.permission.APPROXIMATELY_LOCATION'] // 优先用大致位置
)
if (result.authResults[0] === 0) {
return true
}
return false
} catch (e) {
console.error('Location permission denied: ' + JSON.stringify(e))
return false
}
}
// 如果确实需要精准位置,二次申请
async requestPreciseLocation(): Promise<boolean> {
// 先检查是否已有大致位置权限
const token = this.getContext().accessToken
const hasApprox = await this.checkPermission(token, 'ohos.permission.APPROXIMATELY_LOCATION')
if (!hasApprox) {
return false // 连大致位置都没有,不要直接申请精准
}
const result = await this.accessManager.requestPermissionsFromUser(
token,
['ohos.permission.LOCATION']
)
return result.authResults[0] === 0
}
private async checkPermission(token: number, permission: string): Promise<boolean> {
const status = await this.accessManager.checkAccessToken(token, permission)
return status === 0 // PERMISSION_GRANTED
}
}
权限审计要点
逐个模块检查以下事项:
[ ] 每个
requestPermissionsFromUser调用,是否有明确的触发时机(用户操作而非启动时批量)[ ] 是否存在请求了但从未使用的权限
[ ] 权限被拒绝后是否有降级方案(大致位置替代精准位置、选择器替代全盘文件访问)
[ ] targetSDKVersion 对应的权限级别是否匹配(normal / system_basic / system_core)
[ ] module.json5 中
requestPermissions的reason字段是否填写了中文说明
三、数据存储加密审计
需要加密的数据
Asset Store Kit:对标 iOS Keychain
HarmonyOS NEXT 提供了 Asset Store Kit,专门用于短敏感数据的本地安全存储,基于硬件 TEE 级别加密:
import asset from '@ohos.security.asset'
class SecureStorage {
// 存储
async storeToken(token: string): Promise<void> {
const attributes: asset.AssetMap = {
'ALIAS': { type: asset.AssetType.BYTES, value: this.stringToUint8Array('user_token') },
'SECRET': { type: asset.AssetType.BYTES, value: this.stringToUint8Array(token) },
'ACCESSIBILITY': {
type: asset.AssetType.NUMBER,
value: asset.Accessibility.DEVICE_FIRST_UNLOCKED // 设备首次解锁后可访问
},
'SYNC_TYPE': {
type: asset.AssetType.NUMBER,
value: asset.SyncType.NONE // 不跨设备同步
}
}
try {
await asset.add(attributes)
} catch (e) {
// 如果已存在,先删再加
if ((e as BusinessError).code === 0x8016) {
await asset.remove({ 'ALIAS': attributes.ALIAS })
await asset.add(attributes)
}
}
}
// 读取
async getToken(): Promise<string | null> {
try {
const query: asset.AssetMap = {
'ALIAS': { type: asset.AssetType.BYTES, value: this.stringToUint8Array('user_token') }
}
const results = await asset.query(query)
if (results.length > 0) {
return this.uint8ArrayToString(results[0].SECRET.value)
}
return null
} catch (e) {
console.error('Get token failed: ' + JSON.stringify(e))
return null
}
}
// 删除
async clearToken(): Promise<void> {
await asset.remove({
'ALIAS': { type: asset.AssetType.BYTES, value: this.stringToUint8Array('user_token') }
})
}
private stringToUint8Array(str: string): Uint8Array {
const encoder = new TextEncoder()
return encoder.encode(str)
}
private uint8ArrayToString(bytes: Uint8Array): string {
const decoder = new TextDecoder('utf-8')
return decoder.decode(bytes)
}
}
cryptoFramework:业务数据加密
对于不适合用 Asset Store 的较大数据(如聊天记录),用 cryptoFramework 做 AES 加密:
import cryptoFramework from '@ohos.security.cryptoFramework'
class DataEncryptor {
private keyAlias: string = 'app_aes_key_v1'
// 初始化密钥(首次生成,后续复用)
async initKey(): Promise<void> {
try {
// 尝试获取已有密钥
await this.getKey()
} catch (e) {
// 不存在则生成新密钥
const keySpec: cryptoFramework.HKeySpec = {
algorithm: 'AES256',
keySize: 256,
alias: this.keyAlias,
purpose: cryptoFramework.CryptoPurpose.ENCRYPT_DECRYPT
}
await cryptoFramework.generateKey(keySpec)
}
}
// 加密
async encrypt(plaintext: string): Promise<string> {
const encryptSpec: cryptoFramework.HEncryptSpec = {
algorithm: 'AES256',
alias: this.keyAlias,
purpose: cryptoFramework.CryptoPurpose.ENCRYPT,
data: this.encode(plaintext)
}
const encrypted = await cryptoFramework.encrypt(encryptSpec)
return this.toBase64(encrypted)
}
// 解密
async decrypt(ciphertext: string): Promise<string> {
const decryptSpec: cryptoFramework.HDecryptSpec = {
algorithm: 'AES256',
alias: this.keyAlias,
purpose: cryptoFramework.CryptoPurpose.DECRYPT,
data: this.fromBase64(ciphertext)
}
const decrypted = await cryptoFramework.decrypt(decryptSpec)
return this.decode(decrypted)
}
private async getKey(): Promise<void> {
// 查询密钥是否存在
const keySpec: cryptoFramework.HKeySpec = {
alias: this.keyAlias,
purpose: cryptoFramework.CryptoPurpose.ENCRYPT_DECRYPT
}
await cryptoFramework.getKey(keySpec)
}
private encode(str: string): Uint8Array {
return new TextEncoder().encode(str)
}
private decode(bytes: Uint8Array): string {
return new TextDecoder('utf-8').decode(bytes)
}
private toBase64(bytes: Uint8Array): string {
// Base64 编码逻辑
let result = ''
const len = bytes.length
for (let i = 0; i < len; i += 3) {
const b1 = bytes[i] || 0
const b2 = bytes[i + 1] || 0
const b3 = bytes[i + 2] || 0
result += String.fromCharCode(
(b1 >> 2) & 0x3F,
((b1 & 0x3) << 4) | ((b2 >> 4) & 0xF),
((b2 & 0xF) << 2) | ((b3 >> 6) & 0x3),
b3 & 0x3F
)
}
return btoa(result)
}
private fromBase64(str: string): Uint8Array {
const decoded = atob(str)
const bytes = new Uint8Array(decoded.length)
for (let i = 0; i < decoded.length; i++) {
bytes[i] = decoded.charCodeAt(i)
}
return bytes
}
}
存储安全审计要点
[ ] 用户密码、Token 是否通过 Asset Store Kit 存储(不是明文 Preferences)
[ ] 数据库中敏感字段是否加密(不能明文存身份证号)
[ ] 加密密钥是否通过 Universal Keystore Kit 管理(不是硬编码在代码里)
[ ] 日志中是否打印了敏感数据(搜索 console.log 中的 password/token/phone)
[ ] 网络传输是否全站 HTTPS(不允许 HTTP 回退)
四、隐私合规检查
个人信息收集清单
按照《个人信息保护法》要求,应用收集个人信息必须告知用户并取得同意。上线前整理一份清单:
隐私政策对齐
代码中实际收集的数据,必须和隐私政策文档描述一致。常见违规:
隐私政策说"不收集位置信息",但代码里有
geoLocationManager调用隐私政策说"不使用第三方 SDK",但实际集成了统计/推送 SDK
隐私政策没有提及设备标识收集,但代码用了
deviceInfo.deviceID
权限说明文案
module.json5 中每个权限的 reason 字段必须填写,且文案要具体:
{
"name": "ohos.permission.APPROXIMATELY_LOCATION",
"reason": "$string:permission_location_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "always"
}
}
// resources/base/element/string.json
{
"name": "permission_location_reason",
"value": "用于显示您附近的商家和推荐服务"
}
五、第三方 SDK 审计
引入第三方 SDK 前需要检查:
SDK 审计方法
权限层面:集成 SDK 后,对比 module.json5 中
requestPermissions的变化网络层面:用 DevEco Studio 的 Network Profiler 观察应用启动时的网络请求
代码层面:反编译 SDK 的 HAR 包,检查是否有异常数据上报逻辑
六、代码混淆与反逆向
第 33 篇讲过混淆配置,这里补充审计要点:
// build-profile.json5 中的混淆规则
{
"arkOptions": {
"obfuscation": {
"ruleOptions": {
"enable": true,
"files": ["obfuscation-rules.txt"]
}
}
}
}
# obfuscation-rules.txt
# 保留对外 API
-keep
./src/main/ets/api/
# 保留资源名
-keep-resource-name
# 名称混淆
-enable-name-obfuscation
# 属性名混淆
-enable-property-obfuscation
# 字面量混淆
-enable-literal-obfuscation
# 顶层声明混淆
-enable-toplevel-obfuscation
# 保留异常类名(便于线上 Crash 定位)
-keep-class-name
BusinessError
审计要点:
[ ] obfuscation 是否开启(默认关闭!)
[ ] 对外 API 是否正确保留(-keep)
[ ] 是否保留异常类名(否则 Crash 日志无法定位)
[ ] 反调试是否启用(第 33 篇)
七、安全审计流程清单
把上面的内容汇总成一份可执行的审计清单,按以下顺序逐项检查:
第 1 步:权限审计
[ ] 扫描代码中所有
requestPermissionsFromUser调用[ ] 确认没有使用被禁的 9 类权限
[ ] 确认权限申请有明确触发时机,不是启动时批量申请
[ ] 确认权限被拒绝后有降级方案
第 2 步:数据审计
[ ] 扫描 Preferences/RDB 存储的内容,确认敏感数据已加密
[ ] 确认密钥通过 Keystore 管理,非硬编码
[ ] 扫描日志输出,确认不打印敏感信息
第 3 步:网络审计
[ ] 确认所有网络请求使用 HTTPS
[ ] 确认有证书校验逻辑
[ ] 确认 API 请求有防重放机制(nonce + timestamp)
第 4 步:隐私合规
[ ] 个人信息收集清单与隐私政策一致
[ ] 权限说明文案具体且准确
[ ] 第三方 SDK 有对应隐私政策
第 5 步:混淆加固
[ ] 代码混淆已开启
[ ] 对外 API 正确保留
[ ] 异常类名保留
第 6 步:第三方 SDK
[ ] 每个 SDK 有安全评估报告
[ ] SDK 权限不超出功能范围
[ ] SDK 网络请求已审查
小结
安全审计的本质是在上线前回答一个问题:你的应用收集了什么数据、怎么存的、谁能拿到、用户知不知道。
这份清单不是一次性的——每次发版都应该过一遍。尤其是新增权限、新增 SDK、新增数据收集时,对应的审计项必须更新。
星盾架构提供了系统层的安全能力,但开发者层的安全意识才是第一道防线。系统帮你拦住了不该有的权限申请,但如果你自己把用户 Token 明文存在 Preferences 里,系统也帮不了你。