HarmonyOS 7 敏感数据与密钥管理实战 02:用 KeyStore 管理密钥并完成本地数据加密
上一篇把登录凭据存进了 AssetStore,解决了"敏感数据明文存储"的问题。但还有一个更根本的问题没解决:如果应用里有一些本地数据需要加密存储,比如用户的私有笔记、草稿内容、缓存的敏感信息,数据可以加密,但加密用的密钥到底放哪里?
这个问题看起来简单,实际上是整个加密体系的核心。密钥如果和密文存在一起,那加密就形同虚设——攻击者拿到了密文和密钥,直接就能解密。密钥如果写死在代码里,反编译一下就能拿到。密钥如果存在 Preferences 里,又是明文。
HarmonyOS 提供的 KeyStore 就是用来解决这个问题的。它把密钥存在系统的安全区域里,应用只能通过别名引用密钥,不能直接拿到密钥的原始值。这篇就讲讲怎么用 KeyStore 管理密钥,以及怎么配合加密模块完成本地数据的加密和解密。

一、密钥到底放哪里这个问题
先说说我为什么在这件事上纠结了很久。
最早的做法很简单:用一个固定的字符串做密钥,AES 加密一下数据,存到本地文件里。代码里写死 `const KEY = "my_secret_key_123"`,加密解密都用它。这个方案在开发环境没问题,但只要有人反编译 APK,就能搜到这个字符串,然后解密所有数据。
后来改进了一下,密钥不写死在代码里,而是运行时生成,存在 Preferences 里。但 Preferences 是明文的,等于把密钥写在了密文旁边,还是不安全。
再后来想,密钥用 AssetStore 存行不行?AssetStore 是加密存储的,比 Preferences 安全。但 AssetStore 存的是应用可以读取的数据,也就是说应用代码里能拿到密钥的原始值,然后用这个密钥去加密。如果应用本身有漏洞,密钥还是可能被内存 dump 出来。
KeyStore 的思路完全不同:密钥生成以后就存在系统的安全硬件(TEE 或 SE)里,永远不会以明文形式出现在应用的内存中。应用只能通过别名告诉系统"用这个密钥加密",系统在安全区域里完成加密操作,返回密文。应用从头到尾都拿不到密钥的原始值。
这就是 KeyStore 的核心价值:密钥不可导出,加密操作在安全区域执行。
二、KeyStore 的基本概念和别名机制
在用 KeyStore 之前,有几个概念得搞清楚。
KeyStore 里的密钥用别名(alias)来标识。生成密钥的时候指定一个别名,以后加密解密都通过这个别名引用。别名是字符串,比如 "app_data_key"、"user_private_key"。不同的业务数据可以用不同的密钥,通过别名区分。
生成密钥的时候需要指定算法和参数。常见的对称加密算法是 AES,参数包括密钥长度(128 位或 256 位)、分组模式(CBC、GCM 等)、填充方式。这些参数在生成密钥时指定,以后使用时必须匹配,否则会报错。
密钥有生命周期:生成 → 使用 → 更新 → 删除。密钥可以设置有效期,过期后自动失效。也可以主动删除密钥,删除后用这个密钥加密的数据就无法解密了——所以删除密钥前一定要确认不需要再解密旧数据了。
这里有个关键的设计决定:密钥是全局一个还是按用户一个?我的做法是按用户生成密钥,别名里包含用户 ID,比如 "data_key_user_123"。这样不同用户的数据用不同的密钥,用户退出登录后可以删除该用户的密钥,其他用户的数据不受影响。如果用全局一个密钥,一个用户退出后删除密钥,其他用户的数据就解不开了。
三、KeyManager 负责密钥的生成和生命周期
我把密钥相关的操作封装在 KeyManager.ets 里,负责密钥的生成、查询、删除。业务层不直接操作 KeyStore,而是通过 KeyManager 来获取密钥句柄。
下面这段代码放在 KeyManager.ets 里,实现了密钥的生成和查询。它在应用首次需要加密时调用 generateKey,后续加密解密时调用 getKeyAlias 获取密钥别名。
import { cryptoFramework } from '@kit.CryptoArchitectureKit';
const KEY_ALGORITHM = 'AES';
const KEY_SIZE = 256;
const BLOCK_MODE = 'GCM';
const PADDING = 'PKCS7';
export class KeyManager {
private keyAlias: string = '';
async generateKey(userId: string): Promise<string> {
this.keyAlias = `data_key_${userId}`;
try {
const generator = cryptoFramework.createAsyKeyGenerator(KEY_ALGORITHM);
const keyParam = new cryptoFramework.AesKeyParamsSpec(KEY_SIZE, BLOCK_MODE, PADDING);
await generator.generateKey(keyParam);
console.info(`[KeyManager] key generated, alias=${this.keyAlias}`);
return this.keyAlias;
} catch (e) {
console.error(`[KeyManager] generateKey failed: ${JSON.stringify(e)}`);
throw e;
}
}
getKeyAlias(): string {
if (!this.keyAlias) {
throw new Error('key not initialized, call generateKey first');
}
return this.keyAlias;
}
async deleteKey(userId: string): Promise<void> {
const alias = `data_key_${userId}`;
try {
// 删除指定别名的密钥,具体 API 依版本确认
console.info(`[KeyManager] key deleted, alias=${alias}`);
} catch (e) {
console.error(`[KeyManager] deleteKey failed: ${JSON.stringify(e)}`);
}
}
}
这段代码要解决的问题:generateKey 根据用户 ID 生成唯一别名的 AES 密钥,密钥长度 256 位,GCM 模式;getKeyAlias 返回当前密钥别名,未初始化时抛明确错误;deleteKey 在用户退出时删除对应用户的密钥。
实际运行时要注意:上面的代码是基于通用加密框架的写法,KeyStore 的具体 API(比如密钥是否持久化、如何通过别名引用已生成的密钥)在不同 HarmonyOS 版本中可能有差异。实际接入时需要对照当前 API 版本的文档确认方法名和参数。特别是密钥持久化这一点——有些版本的密钥生成后默认存在内存中,应用重启后就没了;如果需要持久化,需要额外指定参数。这个必须在真机上验证。

四、LocalCrypto 负责加解密,不碰密钥管理
密钥管理和加解密操作应该分开。KeyManager 只管密钥的生成和生命周期,LocalCrypto 负责具体的加密和解密操作。这样拆分的好处是:密钥的生成策略变了(比如从按用户生成改成全局一个),只需要改 KeyManager,LocalCrypto 的代码不用动;加密算法变了(比如从 AES-CBC 改成 AES-GCM),只需要改 LocalCrypto,KeyManager 不受影响。
下面这段代码放在 LocalCrypto.ets 里,实现了数据的加密和解密。它在需要加密本地数据时调用 encrypt,在读取数据时调用 decrypt。
import { cryptoFramework } from '@kit.CryptoArchitectureKit';
export class LocalCrypto {
private keyAlias: string;
constructor(keyAlias: string) {
this.keyAlias = keyAlias;
}
async encrypt(plainData: Uint8Array): Promise<Uint8Array> {
try {
const cipher = cryptoFramework.createCipher('AES/GCM/PKCS7');
// 通过别名获取密钥句柄,具体 API 依版本确认
const key = await this.getKeyByAlias(this.keyAlias);
const iv = cryptoFramework.createRandom(12);
const encParams = new cryptoFramework.GcmParamsSpec(iv, null, 128);
await cipher.init(cryptoFramework.CryptoMode.ENCRYPT_MODE, key, encParams);
const encrypted = await cipher.doFinal(plainData);
// IV 和密文拼接存储,解密时需要同样的 IV
return this.concatIvAndCipher(iv, encrypted);
} catch (e) {
console.error(`[LocalCrypto] encrypt failed: ${JSON.stringify(e)}`);
throw e;
}
}
async decrypt(storedData: Uint8Array): Promise<Uint8Array> {
try {
const { iv, cipherText } = this.splitIvAndCipher(storedData);
const cipher = cryptoFramework.createCipher('AES/GCM/PKCS7');
const key = await this.getKeyByAlias(this.keyAlias);
const decParams = new cryptoFramework.GcmParamsSpec(iv, null, 128);
await cipher.init(cryptoFramework.CryptoMode.DECRYPT_MODE, key, decParams);
return await cipher.doFinal(cipherText);
} catch (e) {
console.error(`[LocalCrypto] decrypt failed: ${JSON.stringify(e)}`);
throw e;
}
}
private async getKeyByAlias(alias: string): Promise<cryptoFramework.SymKey> {
// 通过别名从 KeyStore 获取密钥句柄
// 具体实现依 API 版本确认,此处为示意
throw new Error('getKeyByAlias needs platform-specific implementation');
}
private concatIvAndCipher(iv: Uint8Array, cipher: Uint8Array): Uint8Array {
const result = new Uint8Array(iv.length + cipher.length);
result.set(iv, 0);
result.set(cipher, iv.length);
return result;
}
private splitIvAndCipher(data: Uint8Array): { iv: Uint8Array, cipherText: Uint8Array } {
const ivLen = 12;
return {
iv: data.slice(0, ivLen),
cipherText: data.slice(ivLen)
};
}
}
这段代码要解决的问题:encrypt 生成随机 IV,用 AES-GCM 加密,把 IV 和密文拼接后返回;decrypt 从存储数据中分离 IV 和密文,用同样的参数解密;IV 每次加密都随机生成,不重复使用,提升安全性。
实际运行时要注意:GCM 模式下 IV 长度通常是 12 字节,这个长度不能随便改,加密和解密必须一致。IV 不需要保密,但必须和密文一起存储,因为解密时需要同样的 IV。另外,getKeyByAlias 这个方法是示意代码,实际需要根据 KeyStore 的 API 来实现——通过别名获取密钥句柄,而不是拿到密钥的原始值。这个方法是整个加密体系的关键,如果实现不对,密钥的安全性就无法保证。
五、业务层怎么调用这三层
把 KeyManager 和 LocalCrypto 都封装好以后,业务层的调用就很清晰了。
用户登录成功后,先调用 keyManager.generateKey(userId) 确保该用户的密钥存在。然后创建一个 localCrypto 实例,传入密钥别名。以后需要加密数据时,调用 localCrypto.encrypt(data),拿到密文后存到文件或数据库里。读取数据时,先读出密文,调用 localCrypto.decrypt(cipherText),拿到明文后使用。
用户退出登录时,调用 keyManager.deleteKey(userId) 删除该用户的密钥。密钥删除后,之前加密的数据就无法解密了——这是预期行为,因为用户退出后不应该再能访问其加密数据。如果业务需要保留数据(比如用户重新登录后还能看到),就不能删除密钥,或者需要在删除前把数据解密后重新用新密钥加密。
这里有个容易踩的坑:密钥和密文的对应关系。如果用户 A 的数据用密钥 A 加密,用户 B 登录后用密钥 B 去解密用户 A 的数据,肯定会失败。所以在存储加密数据时,要记录这条数据是属于哪个用户的,读取时先确认当前用户和数据所属用户一致,再解密。

六、密钥失效、异常处理和边界情况
最后说几个需要注意的边界情况。
密钥失效。密钥可能因为各种原因失效:用户修改了设备密码(如果密钥绑定了设备密码)、系统安全策略变更、密钥过期。密钥失效后解密会失败,这时候需要有降级策略:提示用户"数据无法解密,可能需要重新登录",然后走重新生成密钥的流程。但旧数据已经无法恢复了,所以重要数据建议有云端备份。
加密失败。加密操作可能因为密钥不存在、参数不匹配、系统服务异常等原因失败。我的做法是:加密失败时不保存数据,提示用户"数据保存失败",同时打日志记录具体错误。不要把明文存进去当降级——那就失去了加密的意义。
解密失败。解密失败可能是因为密钥不对、数据损坏、IV 不匹配。解密失败时要区分是"密钥不存在"还是"数据损坏":密钥不存在提示用户重新登录,数据损坏提示用户"数据可能已损坏"。两种情况的处理方式不同,不能混为一谈。
应用重启后的密钥恢复。如果密钥是持久化存储的,应用重启后应该能通过别名获取到已生成的密钥。但如果密钥只存在内存中,重启后就没了,需要重新生成——但重新生成的密钥和旧密钥不一样,旧数据就解不开了。所以密钥是否持久化是一个必须确认的关键点,建议在真机上验证应用重启后密钥是否仍然可用。
多密钥管理。如果应用里有多种类型的敏感数据(比如用户数据和应用配置数据),可以用不同的密钥分别加密,通过不同的别名区分。这样即使一个密钥出问题,也不会影响其他数据。但密钥多了管理成本也高,需要权衡。
API 版本兼容性。KeyStore 和加密框架的 API 在不同 HarmonyOS 版本之间可能有差异,包括类名、方法名、参数类型。上面的代码是基于通用写法,实际接入时需要对照当前 API 版本(API 26)的文档确认。建议在封装层做版本判断,兼容不同版本的差异。
把密钥管理和加解密拆成两层,看起来多了几个文件,但实际上职责更清晰了。KeyManager 只管密钥从哪来、什么时候删,LocalCrypto 只管怎么加密解密,业务层只关心"我要加密这个数据"。这种拆分在项目维护的时候特别有用——出了问题能快速定位是密钥的问题还是加密的问题,而不是在一坨代码里翻来翻去。
更多推荐

所有评论(0)