上一篇把登录凭据存进了 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 只管怎么加密解密,业务层只关心"我要加密这个数据"。这种拆分在项目维护的时候特别有用——出了问题能快速定位是密钥的问题还是加密的问题,而不是在一坨代码里翻来翻去。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐