
1. 项目概述与背景最近在做一个跨平台应用核心需求是在鸿蒙OpenHarmony和安卓上共享一套业务逻辑同时要确保用户敏感数据比如登录凭证、本地缓存的安全。Kotlin MultiplatformKMP自然成了首选它允许我们用Kotlin写一次核心逻辑然后分别编译到JVMAndroid和JavaScriptHarmonyOS。但问题来了数据加密这块两边平台的原生API差异不小直接调用平台特定代码就失去了KMP“共享逻辑”的意义。所以我的目标很明确在KMP的共享模块commonMain里实现一套统一、安全、可复用的数据加密方案让鸿蒙的ArkTS和安卓的Kotlin/Java都能无缝调用。这不仅仅是调用一个加密库那么简单。它涉及到在KMP的约束下如何选择加密算法、如何管理密钥、如何处理平台差异以及最终如何将加密后的数据安全地存储或传输。网上关于KMP结合加密的资料比较零散特别是针对鸿蒙端ArkTS调用Kotlin编译的JS的实践更少。这次我就把踩过的坑和最终跑通的方案详细拆解一下如果你也在做跨平台的安全功能这篇内容应该能帮你省下不少折腾的时间。简单说这个方案的核心价值在于用Kotlin在中心写好加密解密、哈希验证等安全逻辑一套代码同时保障安卓App和鸿蒙应用的数据安全避免了两边各自实现可能带来的不一致性和安全漏洞。2. 技术选型与架构设计2.1 为什么选KMP自研加密模块首先得回答为什么不用现成的跨平台加密库或者分别在两端实现。现成的跨平台C库如OpenSSL绑定对KMP的支持尚不成熟集成复杂度高且可能增加包体积。分别在Android用Security或AndroidX Security和HarmonyOS用ohos.security.cryptoFramework实现则会导致业务逻辑重复且一旦加密策略需要调整比如密钥轮换、算法升级需要改两个地方维护成本高。KMP的共享模块commonMain是纯Kotlin代码可以编译成JVM字节码和JS代码。我们的加密核心逻辑放在这里就能同时服务于两个平台。架构上我采用了分层设计接口层commonMain定义加密、解密、哈希等操作的通用接口如interface CryptoService。实现层commonMain使用纯Kotlin/JVM可用的加密库实现上述接口。这里的关键是选择的库必须在Kotlin/JVM和Kotlin/JS两个目标平台都能工作。平台适配层androidMain/harmonyMain原则上核心逻辑都在commonMain平台层应该很薄。但有些平台特定的优化或密钥存储如Android的KeyStore、HarmonyOS的huks需要在这里做桥接。在本方案中为了最大化代码共享我选择在commonMain中实现一个基于密码的密钥派生方案而将最敏感的根密钥种子或加密后的密钥的存储委托给平台特定的安全存储这部分的调用会通过expect/actual机制实现。2.2 加密库的选择Bouncy Castle还是Kotlin自带的在commonMain里我们不能直接用java.security因为JS目标平台不认识它。所以需要找一个纯Kotlin实现或支持多平台的加密库。选项ABouncy Castle(BC)功能极其强大且久经考验但它本质上是一个Java库。虽然可以通过bcprov-jdk18on在Android端使用但无法直接编译到JS。有人尝试通过Kotlin/JS的IR编译器或wasm来移植但过程复杂且不稳定对新手不友好。选项Bkotlinx.crypto这是JetBrains官方维护的一个实验性库旨在提供跨平台的加密原语。但目前截至我实践时其功能较为基础且仍处于实验阶段用于生产环境有风险。选项C纯Kotlin实现的轻量级库例如Tink的Kotlin版本或者一些专注于特定算法如AES-GCM的轻量库。Tink是谷歌出品的安全库设计精良但其KMP支持也还在演进中。我的选择与折中考虑到项目进度和稳定性我决定采用一个务实的策略对于最核心的对称加密AES使用一个经过审计的、纯Kotlin实现的AES库例如aes-kt。对于哈希SHA256和密码学安全随机数生成Kotlin/JS环境可以通过crypto.subtleWeb Crypto API的互操作性来间接实现但这需要写一些JS胶水代码。为了让commonMain代码更统一我最终选择了一个名为krypto的多平台实验性库的简化分支它用纯Kotlin实现了AES和SHA256虽然功能不及其它大型库全面但对于常见的“加密一个字符串或一段JSON”的需求完全够用且依赖简单。关键决策点如果你的应用加密需求非常复杂需要多种算法、数字签名、证书操作那么花精力集成Tink或寻找成熟的KMP加密方案是值得的。但如果主要是对本地存储数据进行AES加密以及对密码进行哈希那么一个轻量的、纯Kotlin的AESSHA256实现是快速启动项目的最佳选择。切记不要自己实现加密算法使用经过验证的库。2.3 整体架构图概念层面[Android App (Kotlin/JVM)] [HarmonyOS App (ArkTS)] | | | (调用共享模块编译后的产物) | (调用共享模块编译后的JS模块) | | ▼ ▼ [KMP Shared Module - commonMain] -- 核心加密逻辑在此 | |--- CryptoManager (单例入口) |--- AESGCMEncryptor (实现AES-GCM加密/解密) |--- SHA256Hasher (实现哈希计算) |--- SecureRandomGenerator (安全随机数生成) |--- KeyDerivation (基于密码的密钥派生PBKDF2) | |--- expect/actual | |--- SecureKeyStorage (平台安全存储接口) | |--- actual Android: 委托给 Android Keystore | |--- actual HarmonyOS: 委托给 ohos.security.huks这个架构确保了加密的核心流程如何加密、如何解密、如何哈希在commonMain中只有一份代码。平台特定的部分被隔离在expect/actual声明的SecureKeyStorage中用于安全地保存那个最关键的“主密钥”或加密密钥的密文。3. 核心加密模块实现详解3.1 密钥管理安全的核心所有加密系统的弱点往往在密钥管理。我们的目标是应用密钥不能以明文形式硬编码在代码中也不能轻易从存储中提取。方案基于用户/设备密码的密钥派生 平台安全存储根密钥种子我们不在代码里写死密钥。而是会在应用首次启动时生成一个高熵的随机字节数组作为“根密钥种子”。这个种子本身不能直接用作加密密钥。密钥派生当需要加密数据时我们使用PBKDF2 (Password-Based Key Derivation Function 2)算法结合以下要素派生出实际的加密密钥密码 (Password)可以是一个由应用生成的、对用户透明的复杂字符串存储在平台安全存储中或者在某些场景下是用户输入的生物特征或PIN码的派生值。为了简化本例使用一个应用内固定的“服务密码”。盐 (Salt)一个随机生成的、与加密数据一起存储的盐值。防止彩虹表攻击确保即使相同的密码每次派生出的密钥也不同。迭代次数 (Iterations)增加计算成本抵御暴力破解建议10万次以上。种子存储派生密钥的“根密钥种子”或“服务密码”必须存储在平台提供的最高安全级别的存储中。Android端使用AndroidKeyStore。它可以将密钥材料保存在硬件安全模块HSM或可信执行环境TEE中操作系统外的应用无法直接读取。HarmonyOS端使用ohos.security.huks(HUKS)。它提供了类似的硬件级密钥保护能力。在commonMain中我们这样设计// commonMain/kotlin/security/KeyManager.kt expect class SecureKeyStorage { fun saveSeed(seed: ByteArray): Boolean fun getSeed(): ByteArray? fun clearSeed(): Boolean } // commonMain/kotlin/security/KeyDerivation.kt object KeyDerivation { private const val PBKDF2_ITERATIONS 100_000 private const val KEY_LENGTH_BITS 256 // AES-256 fun deriveKeyFromPassword(password: String, salt: ByteArray): ByteArray { // 这里使用一个纯Kotlin的PBKDF2实现或调用平台API通过expect/actual。 // 为简化此处展示逻辑。实际应使用如CommonCrypto的KMP绑定或纯Kotlin实现。 // 伪代码return PBKDF2(password, salt, ITERATIONS, KEY_LENGTH_BITS) // 假设我们有一个跨平台的PBKDF2函数 return pbkdf2WithHmacSHA256( password password, salt salt, iterationCount PBKDF2_ITERATIONS, keyLengthBytes KEY_LENGTH_BITS / 8 ) } }3.2 AES-GCM 加密/解密实现AES-GCMGalois/Counter Mode是目前推荐的对称加密模式因为它同时提供了保密性加密和完整性认证。它不需要单独的填充方案且效率较高。在commonMain中我们实现一个AESGCMEncryptor// commonMain/kotlin/security/AESGCMEncryptor.kt class AESGCMEncryptor(private val key: ByteArray) { companion object { private const val GCM_TAG_LENGTH 16 // 128位认证标签 private const val GCM_IV_LENGTH 12 // 推荐96位IV } /** * 加密明文 * param plaintext 明文字节数组 * return 拼接了 IV 密文 Tag 的字节数组方便存储 */ fun encrypt(plaintext: ByteArray): ByteArray { // 1. 生成随机的初始化向量 (IV)。每次加密都必须使用新的IV。 val iv generateSecureRandomBytes(GCM_IV_LENGTH) // 2. 初始化加密器这里调用我们选择的纯Kotlin AES-GCM库 // 伪代码实际调用库的API val cipher AesGcmCipher(key, iv, GCM_TAG_LENGTH) val ciphertextWithTag cipher.encrypt(plaintext) // 3. 将 IV 和 (密文Tag) 拼接在一起。IV不需要保密但必须唯一。 return iv ciphertextWithTag } /** * 解密密文 * param encryptedData encrypt方法返回的字节数组 (IV 密文 Tag) * return 解密后的明文字节数组 * throws SecurityException 如果认证失败数据被篡改 */ fun decrypt(encryptedData: ByteArray): ByteArray { if (encryptedData.size GCM_IV_LENGTH GCM_TAG_LENGTH) { throw IllegalArgumentException(加密数据太短) } // 1. 分离 IV 和 (密文Tag) val iv encryptedData.copyOfRange(0, GCM_IV_LENGTH) val ciphertextWithTag encryptedData.copyOfRange(GCM_IV_LENGTH, encryptedData.size) // 2. 初始化解密器并解密 // 伪代码 val cipher AesGcmCipher(key, iv, GCM_TAG_LENGTH) return cipher.decrypt(ciphertextWithTag) // 内部会验证Tag } } // 安全随机数生成 expect fun generateSecureRandomBytes(size: Int): ByteArray关键点说明IV管理GCM模式要求IV必须是唯一的不重复。我们每次加密都生成一个密码学安全的随机IV并将其与密文一起存储。IV不需要保密。认证标签GCM会自动生成一个认证标签Tag解密时会验证它。任何对密文或IV的篡改都会导致解密失败并抛出异常。这是我们实现数据完整性验证的关键。密钥传入的key必须是固定长度如32字节对应AES-256。这个密钥由前面的KeyDerivation模块派生而来。3.3 哈希计算SHA256实现哈希用于验证数据完整性或安全地存储密码需要加盐。我们在commonMain中实现一个简单的工具类。// commonMain/kotlin/security/HashUtil.kt object HashUtil { /** * 计算字节数组的SHA256哈希值 */ expect fun sha256(bytes: ByteArray): ByteArray /** * 计算字符串的SHA256哈希值返回十六进制字符串 */ fun sha256Hex(input: String): String { val bytes input.toByteArray(Charsets.UTF_8) val hashBytes sha256(bytes) return bytesToHex(hashBytes) } /** * 为密码哈希加盐使用PBKDF2或简单的加盐哈希 * 更安全的方式是使用专门的密码哈希函数如Argon2或bcrypt但在KMP中实现较复杂。 * 这里演示加盐SHA256仅适用于非关键场景关键密码存储应用Argon2。 */ fun hashPasswordWithSalt(password: String, salt: ByteArray): String { val combined salt password.toByteArray(Charsets.UTF_8) return sha256Hex(combined.toString(Charsets.UTF_8)) } private fun bytesToHex(bytes: ByteArray): String { val hexChars CharArray(bytes.size * 2) for (i in bytes.indices) { val v bytes[i].toInt() and 0xFF hexChars[i * 2] 0123456789ABCDEF[v ushr 4] hexChars[i * 2 1] 0123456789ABCDEF[v and 0x0F] } return String(hexChars).lowercase() } }对于expect fun sha256我们需要在平台端实现androidMain可以使用java.security.MessageDigest.getInstance(SHA-256”)。harmonyMain由于编译为JS我们可以通过Kotlin/JS的external声明来调用浏览器的crypto.subtle.digestAPI或者使用一个Kotlin/JS的SHA256 polyfill库。4. 跨平台集成与调用4.1 在Android端集成与调用在androidMain源集中我们需要实现SecureKeyStorage和generateSecureRandomBytes、sha256。// androidMain/kotlin/security/PlatformCryptoImpl.kt actual class SecureKeyStorage { private val sharedPrefs // ... 获取SharedPreferences实例 private val keyAlias my_app_master_key_seed actual fun saveSeed(seed: ByteArray): Boolean { // 警告将种子简单编码后存SharedPreferences是不安全的 // 此处仅为演示。生产环境应使用AndroidKeyStore加密种子后再存储。 val encoded Base64.encodeToString(seed, Base64.NO_WRAP) return sharedPrefs.edit().putString(keyAlias, encoded).commit() } actual fun getSeed(): ByteArray? { val encoded sharedPrefs.getString(keyAlias, null) return encoded?.let { Base64.decode(it, Base64.NO_WRAP) } } actual fun clearSeed(): Boolean { return sharedPrefs.edit().remove(keyAlias).commit() } } actual fun generateSecureRandomBytes(size: Int): ByteArray { val random SecureRandom() val bytes ByteArray(size) random.nextBytes(bytes) return bytes } actual fun sha256(bytes: ByteArray): ByteArray { val digest MessageDigest.getInstance(SHA-256) return digest.digest(bytes) }在Android的Activity或ViewModel中可以这样使用// Android App Code class MainViewModel : ViewModel() { private val cryptoManager CryptoManager.getInstance() fun saveSecretData(secret: String) { viewModelScope.launch(Dispatchers.IO) { val encrypted cryptoManager.encryptData(secret.toByteArray()) // 将encrypted (ByteArray) 保存到文件或数据库 saveToLocalStorage(encrypted) } } fun loadSecretData(): String? { val encryptedData loadFromLocalStorage() ?: return null return try { val decryptedBytes cryptoManager.decryptData(encryptedData) String(decryptedBytes, Charsets.UTF_8) } catch (e: SecurityException) { // 数据被篡改解密失败 null } } }4.2 在HarmonyOS (ArkTS) 端集成与调用这是最具挑战性的一步。KMP的JS目标平台会将我们的commonMain Kotlin代码编译成一个JavaScript模块。鸿蒙的ArkTS基于TypeScript需要调用这个JS模块。第一步配置KMP生成JS模块在build.gradle.kts的kotlin块中确保配置了JS目标kotlin { // ... js(IR) { browser() // 或者 nodejs() 但为了鸿蒙我们通常编译为适用于标准JS环境的模块 binaries.executable() } sourceSets { val commonMain by getting { dependencies { // 你的commonMain依赖 implementation(com.mycompany:krypto:1.0.0) // 假设的纯Kotlin加密库 } } val jsMain by getting { dependencies { // JS平台特定的实现 } } } }编译后会在build/js/packages/project-name/kotlin下生成.js和.meta.js等文件。第二步实现HarmonyOS端的actual声明在jsMain源集中我们需要用JavaScript/TypeScript的方式实现那些expect的函数。由于Kotlin/JS可以直接互操作JS代码我们可以这样写// jsMain/kotlin/security/PlatformCryptoImpl.kt import kotlinx.browser.window actual class SecureKeyStorage { // 注意浏览器/标准JS环境没有像Android KeyStore那样的硬件级安全存储。 // 这是一个重大安全限制。生产级HarmonyOS应用应使用鸿蒙的HUKS。 // 此处为演示使用localStorage不安全。 private val storage window.localStorage private val key my_app_master_key_seed actual fun saveSeed(seed: ByteArray): Boolean { return try { // 将ByteArray转为Base64字符串存储 val encoded seed.toBase64() storage.setItem(key, encoded) true } catch (e: dynamic) { false } } actual fun getSeed(): ByteArray? { val encoded storage.getItem(key) as? String return encoded?.fromBase64() } actual fun clearSeed(): Boolean { storage.removeItem(key) return true } } // 扩展函数将ByteArray与Base64字符串互转需要JS的btoa/atob fun ByteArray.toBase64(): String { // 实现略可用js(...)包装JS代码 return js(btoa(String.fromCharCode.apply(null, this))) as String } fun String.fromBase64(): ByteArray { // 实现略 val binaryString js(atob(this)) as String return binaryString.toByteArray(Charsets.ISO_8859_1) } actual fun generateSecureRandomBytes(size: Int): ByteArray { // 使用Web Crypto API生成安全随机数 val array ByteArray(size) js(window.crypto.getRandomValues(new Uint8Array(array))) return array } actual fun sha256(bytes: ByteArray): ByteArray { // 调用Web Crypto API的subtle.digest // 这是一个异步操作但我们的expect函数是同步的。 // 因此我们需要一个同步的SHA256实现或者将expect改为suspend。 // 为简化这里使用一个同步的JS polyfill例如引入一个SHA256的JS库。 // 假设我们通过NPM引入了 js-sha256 库并在gradle中配置了依赖。 // 伪代码 val hashHex js(sha256.create().update(new Uint8Array(bytes)).hex()) as String return hexStringToByteArray(hashHex) }第三步在鸿蒙ArkTS项目中引入并使用Kotlin/JS模块将KMP编译生成的JS文件如yourproject.js、yourproject.meta.js复制到鸿蒙工程的js目录下例如entry/src/main/js/modules。在鸿蒙的package.json中声明对这个模块的依赖如果它被发布为NPM包则更好。或者直接使用script srcmodules/yourproject.js在HTML中引入对于Web类鸿蒙应用。在ArkTS中通过import语句调用。// entry/src/main/ets/pages/Index.ets // 假设Kotlin模块暴露了一个名为 Crypto 的全局对象 // 实际导出方式取决于Kotlin/JS的编译配置如使用 JsExport import { CryptoManager } from ../js/modules/yourproject // 路径根据实际调整 Entry Component struct Index { State message: string Encrypted Data Here build() { Column() { Text(this.message) .fontSize(20) .margin(10) Button(Encrypt Decrypt) .onClick(() { this.executeDemo() }) } .width(100%) .height(100%) } private executeDemo() { try { const cryptoManager CryptoManager.getInstance(); const plaintext MySecretData123; const encrypted cryptoManager.encryptData(plaintext); // 假设这个方法返回Base64字符串 console.log(Encrypted:, encrypted); const decrypted cryptoManager.decryptData(encrypted); console.log(Decrypted:, decrypted); this.message Decrypted: ${decrypted}; } catch (error) { console.error(Crypto failed:, error); this.message Error: error.message; } } }重要提示在真实的鸿蒙特别是非Web类应用即FA模型中JS的运行环境可能不是完整的浏览器环境window.crypto或localStorage可能不可用。你需要使用鸿蒙提供的ohos.security.cryptoFramework和ohos.data.preferences或ohos.security.huks来实现SecureKeyStorage和generateSecureRandomBytes。这通常意味着你需要写一个更复杂的“胶水层”或者将平台相关逻辑完全移到ArkTS侧通过更精细的expect/actual设计让Kotlin代码调用由ArkTS实现的接口。这涉及到Kotlin/JS与ArkTS的互操作是集成中最复杂的部分。5. 常见问题、调试技巧与安全考量5.1 开发中遇到的典型问题Kotlin/JS 加密库缺失或功能不全现象在commonMain中引用的加密库在JS编译时找不到类或函数。排查检查该库是否支持kotlin-js目标。在库的GitHub或文档中查看多平台支持说明。很多Java库是不支持JS的。解决寻找替代的纯Kotlin实现库或者自己用Kotlin实现核心算法仅限教育或非关键场景生产环境用成熟库。也可以考虑将加密这部分作为“平台相关”实现但这会牺牲代码共享性。鸿蒙端调用JS模块失败现象ArkTS代码中import的模块为undefined或调用函数时报错。排查检查JS文件是否成功复制到鸿蒙项目目录且路径正确。检查Kotlin/JS的导出设置。需要在commonMain中对需要暴露给JS的类或函数使用JsExport注解并在gradle中启用IR编译器及moduleKind设置为commonjs或umd。在浏览器开发者工具如果鸿蒙应用是Web类或HiLog中查看具体的JS错误。解决仔细配置Kotlin/JS的编译输出确保生成的JS模块格式能被鸿蒙的JS运行时识别。参考鸿蒙官方文档中关于引入第三方JS库的部分。加解密结果在Android和HarmonyOS上不一致现象在Android上加密的数据在鸿蒙上解密失败反之亦然。排查这是跨平台加密最常见的问题。逐步检查以下环节是否一致密钥派生密钥的密码、盐、迭代次数是否完全相同算法参数AES密钥长度128/256、GCM的IV长度、认证标签长度是否一致数据编码在将加密后的字节数组ByteArray进行存储或传输时是否使用了相同的编码如Base64或十六进制两端编解码逻辑是否一致数据拼接encrypt函数返回的IV Ciphertext Tag的拼接顺序在解密时拆分逻辑是否完全一致解决编写单元测试在commonTest中模拟完整的加密解密流程确保逻辑正确。然后分别在Android和HarmonyOS的测试中运行定位平台差异。性能问题现象在鸿蒙端特别是通过JS解释执行加密大量数据时感觉缓慢。排查PBKDF2密钥派生过程数万次哈希迭代是计算密集型操作在JS环境中可能阻塞UI。解决将密钥派生操作放在Web Worker对于Web鸿蒙或异步任务中执行避免卡顿。对于大量数据的加密可以考虑分块处理。5.2 安全强化建议密钥存储是命门本示例中为了简化在Android使用了SharedPreferences在JS端使用了localStorage这都是极其不安全的因为Root或越狱设备可以轻易读取。生产环境必须Android使用AndroidKeyStore来生成和存储密钥或至少用它来加密一个存储在别处的密钥。HarmonyOS使用ohos.security.huks(HUKS) 来保护密钥。对于JS环境积极探索如何通过FFI外部函数接口或Native API调用HUKS的C API这是实现真正安全存储的关键。算法与参数使用AES-GCM-256而不是CBC模式。PBKDF2的迭代次数应足够高推荐10万次以上并根据设备性能调整。盐值必须使用密码学安全的随机数生成器生成并且每个加密数据都应使用不同的盐或IV。防御侧信道攻击确保代码执行时间不随密钥或数据内容变化时间侧信道。对于KMP共享的Kotlin代码这点需要依赖底层加密库的实现质量。定期密钥轮换设计密钥轮换机制。当主密钥可能泄露时可以用新密钥重新加密所有数据。5.3 调试与日志在KMP跨平台项目中调试加密逻辑需要一些技巧在commonMain中打印日志使用println或io.github.aakira:napier等多平台日志库。在Android的Logcat和鸿蒙的HiLog中查看输出。单元测试先行为CryptoManager、AESGCMEncryptor、KeyDerivation编写详尽的单元测试放在commonTest中覆盖正常流程、错误输入、边界情况。确保核心逻辑在脱离平台环境时就是正确的。端到端测试编写一个简单的测试页面在Android和鸿蒙应用上分别执行相同的加密-解密操作并对比结果和性能。通过以上步骤我们成功构建了一个基于KMP的、跨Android和HarmonyOS的数据加密方案。它虽然无法做到像原生平台那样深度集成硬件安全特性但在逻辑统一、代码复用和基础安全防护上提供了极大的价值。对于许多需要保护本地敏感数据如用户设置、缓存令牌的应用来说这是一个切实可行的架构。