业务里出现一个需要校验并加密身份证号的三方库,很多团队第一反应是直接引入一个现成的 Flutter 包。我当时在模拟项目X里用的就是 rsa_id_number——在 Android 和 iOS 上跑得挺顺,等把工程切到鸿蒙侧准备出 HAP 包那天,编译直接挂了。更麻烦的是,光靠升级三方库版本根本解决不了,因为问题不在依赖冲突,而在“原生能力缺位”。折腾了一周左右,我把这套“鸿蒙化适配”的完整路径摸了出来:既要把身份标识当作敏感资产来治理,又要把 RSA 加解密、校验位算法、脱敏展示这些事在鸿蒙环境里重新落地。这篇文章就来拆一拆,适合正在做 Flutter 鸿蒙迁移、或者准备把加密类三方库塞进鸿蒙工程的同学参考。
1. 鸿蒙化之前先弄明白:rsa_id_number 这个库的价值和工作边界
1.1 它到底封装了哪几类能力
很多人看到库名里有“rsa”,以为它只是个加密工具,实际上身份标识类业务里需要的通常是四件事一起做:
- 合法性校验:判断一个 18 位号码是否符合现行编码规则,包括正则格式、前 17 位加权求和后的校验码比对、出生日期合法性、地区码是否存在。
- 信息提取:从号码里解析出出生日期、性别、所在行政区划。性别用的是倒数第二位奇偶判断,奇数为男,偶数为女。
- 脱敏展示:默认场景下不能直接显示完整号码,保留前三位和后四位,中间用掩码代替,这是身份标识资产治理里使用频次最高的能力。
- RSA 加解密与签名:传输前用公钥加密,服务端用私钥解密;敏感操作时还要做签名验签,防止号码被中间过程篡改。
这是这类库的标准结构。把它拆开看,核心价值其实分两层:算法层和平台能力层。算法层包括校验规则、脱敏规则、RSA 填充逻辑,这些跟操作系统没有关系,只要编程语言能表达就能跑;平台能力层包括随机数安全熵源、密钥生成、系统级加密框架调用,这些跟操作系统绑定得很深。
1.2 为什么“鸿蒙化”不是升级版本号就能解决
一开始我的想法很简单:既然 Flutter 是跨平台的,把库升级到最新版总该支持鸿蒙了吧。后来发现这个思路有个前提漏洞——Flutter 跨平台只保证“纯 Dart 代码”能跨,一旦涉及原生能力,靠的是各终端的原生工程去实现。
rsa_id_number 里用的加密随机数、密钥对生成,在 Android 上走的是 Android 的加密 Provider,在 iOS 上走的是 Security framework。鸿蒙侧如果没实现对应的原生插件,Flutter 引擎调用到这一层就会断。表现可能是两种:
- 编译期直接报错:插件包没有提供鸿蒙的
ohos目录实现,HAP 打包时找不到对应符号。 - 运行期才暴露:能编译过,但调用
Random.secure()或者密钥生成时抛出类似“找不到实现”的异常。
这就是为什么“升级三方库”治标不治本。鸿蒙 NEXT 的新内核不再兼容 Android APK,原生实现必须专门为鸿蒙环境重新提供,这是绕不开的第一步认知。
2. 从依赖冲突到跑不起来:一次真实适配过程复盘
2.1 排查链路:先分清是 Dart 层还是原生层
我当时遇到的现象比较典型:flutter build hap能过,安装到测试机上之后,打开页面点“加密”按钮,应用直接退出。第一反应是看崩溃日志,日志里指向的调用栈一半在 Dart 侧,一半在加密库的原生侧。
我建议按这个顺序排查,能省很多时间:
- 先单独写一个最小 Flutter 页面,只调
rsa_id_number的校验函数,不动加密。如果校验能跑,说明纯 Dart 的算法层在鸿蒙上没问题。 - 再单独调密钥生成或公钥加密,看是否在这里崩。这一步能确认问题是不是出在原生能力映射上。
- 如果加密崩了,把依赖里带
android、ios目录的插件逐个列出,重点检查它们有没有ohos目录或鸿蒙适配文件。
在我这个案子里,纯校验逻辑正常,加密部分崩,定位到是底层加密 Provider 缺失。这就引出了选型问题:是给这个插件补一个鸿蒙实现,还是换一种对接方式。
2.2 三条路线怎么选:重编译、桥接、还是自研替换
实战中通常会碰到三条路线,我把它们放在一起对比过:
| 路线 | 改造量 | 风险 | 性能 | 维护成本 |
|---|---|---|---|---|
| A. 纯 Dart 重编译 | 小,只调 gradle/ohos 配置 | 中,随机数熵源或加密 Provider 仍可能依赖平台 | 一般 | 中 |
| B. 算法层保留 + 原生能力桥接到鸿蒙 cryptoFramework | 中,需要写通道 | 低,系统框架能力可靠 | 高,可走硬件加速 | 低 |
| C. 完全自研替换 | 大,校验、加解密全重写 | 高,容易引入密钥管理漏洞 | 取决于实现 | 高 |
我的选择是 B:校验位、脱敏这些纯算法逻辑尽量保留在 Dart 侧,因为它们在 Android 和 iOS 上已经被验证过;RSA 密钥生成、加解密这些平台相关操作下沉到鸿蒙系统加密框架 cryptoFramework,通过通道暴露给 Dart 层调用。
这样做的理由很实际:算法层不重写,回归风险小;平台层用系统级加密框架,比自己在 Dart 里实现 RSA 更稳,而且鸿蒙系统的加密框架能利用设备级安全硬件做密钥保护,这一点对身份标识类敏感数据非常重要。
2.3 一个容易被忽略的边界:插件是给纯 Flutter 工程用,还是混合工程用
鸿蒙上 Flutter 工程的形态和 Android 不太一样。如果是纯 Flutter 工程,插件入口在entry模块里;如果是混合开发,Flutter 模块被嵌入到原生 HAP 工程里,插件注册时机、通道生命周期都不一样。
我踩过的坑是:在原生工程里先初始化了 ArkTS 侧的加密服务,但 Flutter 引擎还没绑定完成,通道注册失败,导致 Dart 侧调用时拿到“通道不存在”的异常。后来改成在 Flutter 创建完成后统一注册,问题才消失。适配时要先把工程形态定下来,这决定了插件代码挂载方式。
3. 核心算法复刻:身份证校验位规则在鸿蒙上的无损移植
3.1 校验位规则回顾:为什么最后一位能暴露输入错误
现行 18 位身份证号码由 17 位本体码和 1 位校验码组成。前 6 位是行政区划,中间 8 位是出生日期,接下来 3 位是顺序码,最后 1 位是校验码。校验码不是随机生成的,它由前 17 位加权计算而来。
算法如下:
- 权重数组固定为
[7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2] - 前 17 位数字分别乘以对应权重后求和
- 和值对 11 取余
- 余数对应校验码表
['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']中的字符
这样设计的好处是,相邻两位互换、错填一位这类常见手工录入错误,有很大概率在最后一位校验码上暴露出来。所以校验码算法是身份标识校验的核心,也是移植中优先级最高的部分。
3.2 Dart 与 ArkTS 的对照实现
Dart 侧原来的逻辑大概是这样的:
const List<int> weight = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]; const String checkCodes = '10X98765432'; bool validateIdNumber(String raw) { final id = raw.toUpperCase(); if (!RegExp(r'^\d{17}[\dX]$').hasMatch(id)) { return false; } var sum = 0; for (var i = 0; i < 17; i++) { sum += int.parse(id[i]) * weight[i]; } return checkCodes[sum % 11] == id[17]; }在鸿蒙侧,ArkTS 的写法要保持算法逻辑完全一致,避免引入行为偏差:
const WEIGHTS: number[] = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]; const CHECK_CODES: string = '10X98765432'; function validateIdNumber(raw: string): boolean { const id = raw.toUpperCase(); const pattern = /^\d{17}[\dX]$/; if (!pattern.test(id)) { return false; } let sum = 0; for (let i = 0; i < 17; i++) { sum += Number(id[i]) * WEIGHTS[i]; } return CHECK_CODES[sum % 11] === id[17]; }两个版本必须通过同一组数据做交叉验证。我当时构造了一批模拟号码来做单测,比如990011199912311351X这种纯测试用结构,地区码、生日、顺序码都是按规则生成的假数据,不是真实身份信息。在 Dart 侧校验通过,在 ArkTS 侧校验也要通过,结果不一致就说明移植出了问题。
3.3 移植时最容易翻车的地方:大整数和字符串索引
表面看两个版本代码很像,实际容易在三个细节上翻车:
int.parse(id[i])的行为差异:Dart 里字符串下标拿到的是单字符字符串,直接转数字没问题;ArkTS 里如果用默认的字符转数字再替换,容易把X也算进去。必须先做整体正则校验,保证走到加权那一步一定是纯数字。- 大数值溢出:前 17 位加权求和最大也就是 100 多,正常用 int 不会溢出。但如果有人改算法时不注意,用 Decimal 或浮点型求和,结果可能因为精度误差导致校验码错位。不要用 double,整数计算就是整数计算。
- 校验码大小写:用户输入小写
x,在 Dart 侧通过toUpperCase统一处理,ArkTS 侧也要做同样处理。两个平台大小写行为本身没有差异,但如果你把校验逻辑再封装给后端接口,一定要约束同一份规则,否则会出现“前端校验通过,后端校验失败”的尴尬情况。
把校验函数放到独立的工具类里,不掺和加密逻辑,是这次适配后我养成的习惯。算法层和序列化层分离,后面排查问题会轻松很多。
4. RSA 治理实战:密钥、填充、跨端互操作和短数据细节
4.1 密钥规范:从格式到存储都不能随心所欲
RSA 在跨端场景里最容易出问题的不是算法本身,而是密钥编码。鸿蒙的 cryptoFramework 导出密钥时,默认的 DER 编码结构可能和 Flutter 侧加密库期望的格式不一致。
常见的有两种格式:
- PKCS#1:只包含 RSA 的模数和公钥指数,结构简单。
- PKCS#8:外层套了算法标识,是更通用的格式。
如果两端对密钥格式的预期不一致,解密时会直接报错或者解析出错误密钥。我遇到的情况是:鸿蒙导出的公钥是 PKCS#8 的 PEM,而 Dart 侧某个纯加密实现只认 PKCS#1,导致加密结果到了服务端解不开。
规范化做法是三个统一:
- 统一密钥格式:内部全部用 PKCS#8,导出和导入都走同一套转换。
- 统一填充模式:加密用 OAEP 或 PKCS1 v1.5,签名用独立配置,不要混用。
- 统一 Base64 编码规则:PEM 里的换行符、头尾标记要按标准处理,有些库会在 Base64 字符串里带换行,必须做归一化后再传给另一端。
密钥存储上,公钥可以放代码里或配置中心,私钥则不应该出现在 Flutter 层。理想状态是私钥只存在于服务端或鸿蒙安全硬件环境,端侧拿到私钥本身就是治理失败。
4.2 加密基操:鸿蒙 cryptoFramework 对接 Dart 的调用链
Dart 侧原本封装了encryptIdNumber之类的方法,内部直接调用插件。鸿蒙化之后,我建议 Dart 侧只保留方法签名,实际加密交给通道转发到 ArkTS:
const MethodChannel _channel = MethodChannel('app/id_rsa_channel'); Future<String> encryptIdNumber(String plainText) async { final result = await _channel.invokeMethod('rsaEncrypt', { 'plain': plainText, }); return result as String; }ArkTS 侧注册通道后,调用系统加密框架:
import { cryptoFramework } from '@kit.BasicServicesKit'; const generator = cryptoFramework.createAsyKeyGenerator('RSA2048'); const keyPair = await generator.generateKeyPair(); const cipher = cryptoFramework.createCipher('RSA_PKCS1_v1_5'); await cipher.init(cryptoFramework.CryptoMode.ENCRYPT_MODE, keyPair.pubKey, null); const cipherText = await cipher.doFinal(Uint8Array.fromText(plainText));这两段代码表达的是调用逻辑,具体 API 名称要对照你当前 SDK 版本。核心思路是:Dart 只传明文,不碰密钥;ArkTS 侧拿到明文后加密,再把 Base64 密文返回。
这里有一个很多人会踩的坑:不要把密文打印到日志里。密文本身虽然不能直接还原,但它泄露了加密模式和密钥指纹,配合其他信息可以做侧信道分析。身份标识资产的治理原则是:凡是和身份号有关的数据,默认不出现在日志、崩溃上报、埋点里。
4.3 短数据也要注意的边界:填充造成的长度上限
身份证号只有 18 位,很多人觉得 RSA 加密随便做。但换到另一头,服务端解密时如果约定的是“分块加密”,那每块长度上限就是关键参数。
以 2048 位密钥为例:
- PKCS1 v1.5 填充:单次最多能加密
256 - 11 = 245字节。 - OAEP 配合 SHA-256:单次最多能加密
256 - 2 - 32 = 222字节。
身份证号 18 字节,远小于上限,不需要分段。但如果你把加密能力抽象成通用方法,未来可能有人拿去加密地址、手机号、长文本,这时不做长度校验就会出现“数据过长”的运行时异常。我在工具类里加了一个前置检查,超过上限就抛业务异常,而不是等加密框架报错。
加密之后,把字节数组转 Base64 返回。注意不要用 UTF-8 字符串直接承载二进制密文,那会导致密文损坏、服务端解密失败。这个低级错误在联调阶段很容易出现,排查时第一反应先检查编码。
4.4 签名验签和加密要分开,不能因为是同一个 RSA 就共用密钥
如果用同一对密钥既做加密又做签名,风险在于私钥的两种用途混在一起,密钥泄露面变大。比较好的做法是:
- 传输加密用一对密钥:公钥在端侧,私钥在服务端。
- 接口签名用另一对密钥:私钥在服务端,公钥在端侧。
这样即使端侧的公钥被替换,攻击者也无法伪造签名;即使端侧加密公钥泄露,也拿不到私钥解密数据。密钥用途分离是身份标识资产治理里的基础规范,适配过程中顺手就能做掉。
5. 鸿蒙工程改造落地:目录、配置与调用链设计
5.1 工程结构:原生能力放哪里最合适
无论是纯 Flutter 还是混合工程,我都建议把鸿蒙原生能力单独抽成一个模块,不要散落在entry里。结构大致是这样:
ohos目录存放 ArkTS 插件实现lib目录存放 Dart 侧封装example只作为联调样例
插件实现内部再拆分:
id_crypto.dart:对外暴露validate、encrypt、decrypt、mask等方法id_crypto_ohos.ets:鸿蒙侧的通道入口,只负责把 Dart 的调用转换成 cryptoFramework 操作id_crypto_stub.dart:非鸿蒙平台回退逻辑,保证开发调试时还能在别的平台跑
这样抽的好处是,将来如果鸿蒙 SDK 更新,只需要改ets文件,Dart 侧代码和业务调用方完全不用动。
5.2 通道设计:参数、超时和错误返回都需要规范
MethodChannel 的调用链路有个特点:Dart 侧和 ArkTS 侧是异步通信,任何一侧抛异常,另一侧拿到的错误信息可能被简化成笼统的PlatformException。
我的建议是通道协议里统一规定三件事:
- 入参必须是非敏感的上下文对象:比如只传明文数据和会话 ID,不要在参数里带密钥、盐值。
- 超时机制必须有:有些加密操作在设备繁忙时会超过默认超时,要给一个可配置的业务超时,避免页面卡死。
- 错误码要语义化:约定
1001是格式错误、1002是超时、1003是密钥未初始化,这样上层能针对不同错误给用户不同提示,而不是统一弹一个“操作失败”。
另外,通道本身也要做并发保护。连续快速调用 RSA 加密时,如果每次都重新生成密钥对,性能和资源开销都很大。实测下来更稳的做法是:应用启动时初始化一次密钥上下文,后续加密请求复用已加载的密钥,只在极少数场景下重新加载。
5.3 适配完成后的三层验证清单
单测过了并不代表真正适配完成。我建议验证清单包含三层:
| 层级 | 验证内容 | 通过标准 |
|---|---|---|
| 算法层 | 校验码、脱敏规则在 Dart 和 ArkTS 上跑同一批模拟数据 | 两端结果完全一致 |
| 通道层 | Dart 调用 ArkTS 加密后,再用 ArkTS 解密 | 明文与原始输入逐字节一致 |
| 业务层 | 在真实业务路径里跑完整流程,包括日志、埋点、并发模拟 | 无明文身份证号泄漏,无异常退出 |
其中通道层验证最值得单独做:Dart 加密、ArkTS 解密是一条链路;ArkTS 加密、Dart 解密是另一条链路。两条链路都要走通,否则相当于只验证了单向通道,服务端或者本地的数据解密还可能是断的。
6. 身份数据合规治理:鸿蒙适配之外更重要的事
6.1 明文身份号码最容易漏的三个出口
代码里写着写着,身份证号就不知不觉流出去了。最容易漏的三个出口,我在适配过程中都撞到过:
- 日志出口:联调时随手
print入参和返回值,一不小心就把明文打到了日志文件里。适配过程中我特意全局搜索了print、console、Log等关键字,凡是和加密模块相邻的日志一律去掉身份信息。 - 崩溃上报出口:如果崩溃时异常对象携带了明文输入,上报平台会记录完整堆栈。这个问题不查不知道,一查吓一跳。
- 数据库明文出口:本地缓存如果直接存明文,等于把身份标识资产放在一个容易拿的地方。至少要做加密存储,哪怕只是用系统 Keystore 包装过的密钥做对称加密。
治理的第一步不是加复杂系统,而是先做盘点和收敛:哪些页面会展示明文、哪些接口会传明文、哪些函数会接收明文,全部列出来。鸿蒙适配这个过程其实是很好的梳理机会,因为你会重新审视一遍代码调用链。
6.2 密钥要分级,不能一张证书打天下
身份标识相关业务至少要有两套 RSA 体系:
- 一套用于传输加密,保护数据在客户端到服务端链路上的安全。
- 一套用于签名验签,保证请求来源可信、数据没有被篡改。
如果系统里有多个业务场景,密钥还要按业务维度隔离。比如用户资料场景和金融级核身场景不能共用一个密钥对。一旦某个业务场景的私钥泄露,隔离的设计能把影响范围控制在单个场景内,而不是整个系统崩盘。
6.3 前端展示最小化:脱敏不是可有可无的功能
我在适配时把脱敏函数单独抽成了一个方法:
String maskIdNumber(String raw) { if (raw.length != 18) return raw; return raw.replaceRange(3, 14, '***********'); }保留前三位和后四位,中间部分用掩码替代。为什么保留前三位?因为前三位是行政区划代码,用于地域识别;保留后四位则方便用户在个别场景下核对自己的号码尾部。中间 11 位涉及生日和顺序码,是最敏感的部分。
脱敏要默认生效。只有在身份认证、开卡、授权这类明确需要完整号码的场景,才在二次确认后展示明文。这个原则听起来简单,但真正落地需要在前端组件、接口返回值、日志工具三个层面同时执行,少一环都可能漏。
这次鸿蒙化适配做完之后,我最大的体会是:跨端移植表面上是在处理编译报错、通道注册、API 差异,实际上是对整个身份标识处理流程做了一次深度梳理。校验算法给所有输入兜底,RSA 加密给传输兜底,脱敏和密钥分级给业务治理兜底。后续如果要把这套能力再扩展成别的加密工具库,我也会坚持同一个原则:算法层可移植、平台层可替换、密钥和日志治理不可妥协。