只要应用里出现"要存一个不该被明文看到的东西",就会用到对称加密。react-native-aes-crypto 是 RN 生态里用得比较久的那个:AES 加解密、PBKDF2 口令派生、HMAC、SHA 摘要、安全随机数——一套做本地加密和完整性校验需要的原语基本齐了。

这个库在鸿蒙上没有官方实现,所以我做了一版适配。它和前面几个库有个明显不同:它是密码学库,输出是确定性的——这意味着验证不能停在"调用没报错",而应该拿已知值逐字节比对。所以本文的验证部分做得比较重:设备侧跑了 159 项断言全部通过,PC 端又用 Node/OpenSSL 独立复算了一遍库自带的向量。

环境准备:本文不重复环境搭建步骤。RNOH(React Native for OpenHarmony)开发环境的完整配置见官方开发者指南:
https://atomgit.com/CPF-RN/docs/blob/main/开发者指南/02-搭建准备/环境初始化.md

在这里插入图片描述


一、版本配套:四件套必须对齐

RNOH 项目有个硬约束:RN 版本、RNOH 的 npm 包、RNOH 的 ohpm 包、DevEco SDK 四者必须对齐,错一个就是编译报错或者白屏。而且版本矩阵只是通用参考,具体库验证过的组合才算数。

我这次锁定的组合:

项版本
react-native-aes-crypto3.3.0(与 npm 上游 latest 一致)
React Native0.84.1
React19.2.3
@react-native-oh/react-native-harmony(npm)0.84.3
@rnoh/react-native-openharmony(ohpm)0.84.3
Compile SDK26.0.0
DevEco Studio26.0.0 Release
实现方式TurboModule + CAPI 架构,密码学全部走系统 cryptoFramework

写文章前我用 npm view react-native-aes-crypto version 核对过,上游 latest 就是 3.3.0,和适配 TAG 的上游部分一致。

二、适配步骤

第一步:上游同步到 AtomGit

这个库不是 Expo 系的 monorepo 子包,它在自己的仓库里:tectiv3/react-native-aes。

我的做法是在 oh-react-native 组织下建一个独立仓库,把上游源码同步过来,并锁死基线 commit:

upstreamCommit: 5ee374cd2dbe94c413bee7561b1e27a910ce8ee4

锁 commit 这一步不能省——以后想复现"这版适配对应上游哪份代码"就靠它。这条信息我写进了仓库的 spec.json。

第二步:本地克隆

git clone https://atomgit.com/oh-react-native/react-native-aes-crypto.git
cd react-native-aes-crypto

第三步:确定交付分支与版本号

适配包是要被别的主程按版本引用的,所以版本号必须能一眼看出"上游版本 + 鸿蒙实现版本"。我用 main 作开发分支,完成后打 TAG 交付:

git tag 3.3.0-ohos-1.0.0

命名规则是 <上游版本>-ohos-<适配版本>。调用方按 TAG 引用:

"react-native-aes-crypto": "git+https://atomgit.com/oh-react-native/react-native-aes-crypto.git#3.3.0-ohos-1.0.0"

第四步:适配实现——新增了什么、为什么

新增的第一块是 HAR 工程 harmony/aes/:

harmony/aes/
├── Index.ets                            # 导出 AesPackage
├── oh-package.json5                     # 声明包名 @react-native-ohos/react-native-aes-crypto
├── build-profile.json5
└── src/main/
    ├── module.json5
    ├── cpp/                             # CAPI 架构下的 C++ 侧(这里只是个注册壳)
    │   ├── CMakeLists.txt
    │   ├── AesPackage.h                 # Package + TurboModule 工厂 + methodMap
    │   └── AesPackage.cpp
    └── ets/
        ├── AesPackage.ets               # 把 TurboModule 交给 RNOH
        └── AesTurboModule.ts            # ★ 真正的实现(全部密码学逻辑)

第二块是 TurboModule 的实现。上游 JS 侧声明了 11 个方法,逐个落到鸿蒙:

JS 侧原生方法同步/异步鸿蒙实现
encrypt / decryptasyncAES 128/192/256 的 CBC 与 CTR
pbkdf2async系统 KDF,PBKDF2|SHAxxx
pbkdf2Sync同步同上,用 generateSecretSync
hmac256 / hmac512async系统 MAC
sha1 / sha256 / sha512async系统摘要
randomKeyasync系统安全随机
randomUuidasyncutil.generateRandomUUID(true)

注意 C++ 侧这一张表里,只有一个是同步的:

methodMap_ = {
  ARK_ASYNC_METHOD_METADATA(encrypt, 4),
  ARK_ASYNC_METHOD_METADATA(decrypt, 4),
  ARK_ASYNC_METHOD_METADATA(pbkdf2, 5),
  ARK_METHOD_METADATA(pbkdf2Sync, 5),        // ← 保留同步契约
  ARK_ASYNC_METHOD_METADATA(hmac256, 2),
  ...
};

ARK_METHOD_METADATA 与 ARK_ASYNC_METHOD_METADATA 的区别就是同步与 Promise。上游的 pbkdf2Sync 是同步 API,适配必须保持同步——把它改成 Promise 会直接破坏调用方代码。

第三块是 package.json 里的 autolinking 声明:

"harmony": {
  "alias": "react-native-aes-crypto",
  "autolinking": {
    "ohPackageName": "@react-native-ohos/react-native-aes-crypto",
    "etsPackageClassName": "AesPackage",
    "cppPackageClassName": "AesPackage",
    "cmakeLibraryTargetName": "rnoh_aes"
  }
}

这四个名字是 RNOH 找到这个包的凭据。少一个或者拼错,表现都是"编译过了但模块没注册",运行时才发现,很难查。

第五步:补全适配仓库所需的额外文件

上游 README 原文我没动,适配相关的东西单独成文件:

文件作用
README.OpenHarmony.md / README.OpenHarmony_CN.md适配说明:能力对照、版本配套、接入方式、已知限制
spec.json机器可读的适配规格:包名、模块名、方法清单、版本配套、基线 commit、验证结论
RN_react-native-aes-crypto+代码检查报告.md代码检查结论、六个真机场景、限制说明
harmony/aes.har预编译产物(4.6 KB),随包分发
__tests__/vectors.json已知值向量(47 条记录):AES / PBKDF2 / 摘要 / HMAC
__tests__/aes.test.cjs六组契约测试(node --test)

spec.json 里我记了一份验证数据,方便后来人核对:

"validation": {
  "status": "pass",
  "date": "2026-09-13",
  "tag": "3.3.0-ohos-1.0.0",
  "tests": 6,
  "deviceScenarios": 6,
  "knownValueAssertions": 78,
  "rom": "OpenHarmony-7.0.0.105",
  "hapSha256": "4ddbf303fa7c015dbf70f797bdfb42a9a23a82262b6750190edcbf6533f493b9"
}

第六步:代码推送

git push origin main
git push origin 3.3.0-ohos-1.0.0

三、这个适配包长什么样

react-native-aes-crypto/
├── package.json              # 含 harmony.autolinking
├── spec.json                 # 适配规格
├── src/
│   ├── index.ts              # 11 个公开 API + 默认导出
│   └── NativeAes.ts          # TurboModule 的 TS 声明
├── harmony/
│   ├── aes.har               # 预编译产物(4.6 KB)
│   └── aes/                  # HAR 源码
├── __tests__/
│   ├── vectors.json          # 已知值向量(47 条记录)
│   └── aes.test.cjs
└── README.OpenHarmony*.md

要点:

  1. 它是"带原生实现的适配包",必须编译原生代码,不能只 npm install 就完事,还要走 ohpm 和 hvigor。
  2. files 字段里包含 harmony,所以从 git 装依赖时能直接拿到 HAR。
  3. 它没有依赖 expo-modules-core(这本来就不是 Expo 库),是按 RNOH 的 TurboModule + autolinking 规范直接实现的。
  4. 默认导出和具名导出都可以用:
export default {encrypt, decrypt, pbkdf2, pbkdf2Sync, hmac256, hmac512, sha1, sha256, sha512, randomKey, randomUuid};

__tests__/aes.test.cjs 里有一条断言专门盯这个:assert.equal(api.default.encrypt, api.encrypt)。

四、接入宿主:三处改动面(外加一处自动生成的)

库本身不能独立运行,必须有一个 RNOH 宿主 App。社区已有现成的——oh-react-native/RNOH084Demo 是 RNOH 0.84.3 的多库验证宿主,版本和我这版适配完全一致,而且自带一个很实用的机制:

// harmony/entry/src/main/ets/entryability/EntryAbility.ets
const rnAppKey = want.parameters?.['rnAppKey'] as string | undefined;
AppStorage.setOrCreate('rnAppKey', rnAppKey ?? 'RNOH084Demo');

Index.ets 里 RNApp 的 appKey 取自它,于是一个宿主可以挂很多独立测试页,用命令行参数切换:

hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey AesTestApp

接入要改的地方

第一处:package.json。

"react-native-aes-crypto": "file:../react-native-aes-crypto"

第二处:两级 oh-package.json5 都要写 HAR。

"@react-native-ohos/react-native-aes-crypto":
  "file:../node_modules/react-native-aes-crypto/harmony/aes.har",

harmony/oh-package.json5 管工程级、harmony/entry/oh-package.json5 管模块级,两处都要加。只加一处会出现"能找到包但链接不上"。

这里有个很容易漏的点:跑 link-harmony 时,它只会自动更新工程级那一份,模块级那份要你自己加。详见第七节坑五。

第三处:在 ETS 侧注册 Package。

// harmony/entry/src/main/ets/RNOHPackagesFactory.ets
import type { RNPackageContext, RNOHPackage } from '@rnoh/react-native-openharmony';
import AesPackage from '@react-native-ohos/react-native-aes-crypto';

export function createRNOHPackages(ctx: RNPackageContext): RNOHPackage[] {
  return [
    new AesPackage(ctx),
  ];
}

代码写在哪,这里说清楚:手工改动面就是这三个文件(外加 metro.config.js 的 watchFolders)。C++ 侧不用手改——CAPI 架构下 PackageProvider.cpp 会自动消费 autolinking 生成的 RNOHPackagesFactory.h。

那"自动生成的一处"是什么? 执行 link-harmony 时,它会一次性重写这四个文件:

• harmony/entry/src/main/cpp/RNOHPackagesFactory.h   # C++ 侧注册
• harmony/entry/src/main/cpp/autolinking.cmake       # add_subdirectory + 链接
• harmony/entry/src/main/ets/RNOHPackagesFactory.ets # ETS 侧注册
• harmony/oh-package.json5                           # 工程级 HAR 依赖

这四个文件头部都写着 DO NOT modify it manually, your changes WILL be overwritten.——别手改。

五、实现上的设计点

点一:C++ 侧只是个注册壳,密码学全部走系统能力

这一点和前面几个库都不一样。aes.har 的 C++ 只有一个文件、39 行,里面一行密码学代码都没有:

class Aes : public ArkTSTurboModule {
 public:
  Aes(const ArkTSTurboModule::Context ctx, const std::string& name) : ArkTSTurboModule(ctx, name) {
    methodMap_ = { /* 11 个方法登记 */ };
  }
};

真正的实现全在 AesTurboModule.ts 里,底层是鸿蒙的 @ohos.security.cryptoFramework:

import crypto from '@ohos.security.cryptoFramework';

const key = await crypto.createSymKeyGenerator(options.keyAlgorithm).convertKey({data: options.key});
const cipher = crypto.createCipher(options.transformation);
await cipher.init(mode, key, params);
const result = await cipher.doFinal(input.length ? {data: input} : null);

这是个值得学的取舍:密码学不要自己实现,也不要在 NDK 里链第三方库——直接用系统提供的密码能力,既省了编译体积,也把"算法实现正确性"这件事交给了系统。代价是系统的怪行为你得一个个挡掉(点五就是这些)。

点二:格式对齐上游 iOS——CBC 是 PKCS7/Base64,CTR 是 NoPadding/hex

这是最容易踩坑的一处,因为上游自己 iOS 和 Android 就不一致。我选择对齐 iOS:

function aesOptions(key: string, iv: string, algorithm: string): AesOptions {
  if (typeof algorithm !== 'string' || !/^aes-(128|192|256)-(cbc|ctr)$/.test(algorithm)) throw new TypeError('Invalid AES algorithm');
  const bits = Number(algorithm.split('-')[1]), keyBytes = fromHex(key), ivBytes = fromHex(iv);
  if (keyBytes.length !== bits / 8) throw new TypeError('AES key size does not match algorithm');
  if (ivBytes.length !== 16) throw new TypeError('AES IV must be 16 bytes');
  const ctr = algorithm.endsWith('-ctr');
  return {key: keyBytes, iv: ivBytes, keyAlgorithm: `AES${bits}`, ctr,
    transformation: `AES${bits}|${ctr ? 'CTR|NoPadding' : 'CBC|PKCS7'}`};
}

于是输出格式也跟着分叉:

return options.ctr ? hex(result) : base64.encodeToStringSync(result);
填充密文编码
CBCPKCS7Base64
CTRNoPadding十六进制

这一条对调用方是强约束:如果你从上游 Android 版本迁移过来,历史 CTR 密文是 Base64 + PKCS5Padding,和这套协议不能互相解密,本包也不会自动识别转换。迁移时必须自己处理存量数据。这条我明确写进了库的 README——接口兼容性里的这种"沉默不兼容"必须提前说清楚。

点三:pbkdf2 的长度单位是"位",而且要整字节

上游的 length 参数单位是位,不是字节。系统 KDF 要的是字节,所以这里必须换算,并且校验"整字节":

function derivation(password: string, salt: string, cost: number, length: number, algorithm: string): crypto.PBKDF2Spec {
  if (typeof algorithm !== 'string' || !/^sha(1|256|512)$/i.test(algorithm)) throw new TypeError('Invalid PBKDF2 hash');
  if (!Number.isSafeInteger(cost) || cost < 1) throw new TypeError('PBKDF2 cost must be a positive integer');
  if (!Number.isSafeInteger(length) || length < 8 || length % 8 !== 0) throw new TypeError('PBKDF2 length is a positive multiple of 8 bits');
  return {algName: 'PBKDF2', password: utf8(password), salt: utf8(salt), iterations: cost, keySize: length / 8};
}

三个校验都不是摆设:

  • length < 8 || length % 8 !== 0 —— length: 257 这种"非整字节"的位长度必须拒绝,否则 keySize 会是小数;
  • cost 要求正整数——cost: 0 会让 KDF 退化成没有迭代;
  • 摘要名限定 sha1|sha256|sha512——传 md5 直接拒绝。

契约测试里正好有一条盯着这组边界:['pw','salt',0,256,'sha256']、['pw','salt',1,257,'sha256']、['pw','salt',1,256,'md5'] 三个都要抛。

点四:HMAC 的两个边界——空密钥和超长密钥

HMAC 的标准(RFC 2104)里有两处容易实现错的细节,这里都处理了:

private async hmac(text: string, hexKey: string, algorithm: string): Promise<string> {
  const input = utf8(text);
  let bytes = fromHex(hexKey);
  // HMAC hashes keys longer than its block size before padding them.
  if (bytes.length > (algorithm === 'SHA256' ? 64 : 128)) {
    const md = crypto.createMd(algorithm);
    await md.update({data: bytes});
    bytes = (await md.digest()).data;
  }
  // HMAC pads an empty key with zero bytes; one zero byte has the same padded key.
  const key = await crypto.createSymKeyGenerator('HMAC').convertKey({data: bytes.length ? bytes : new Uint8Array(1)});
  ...
}
  • 密钥长于分组长度时,要先做一次摘要再当密钥用(SHA256 分组 64 字节、SHA512 分组 128 字节);
  • 空密钥按标准补零——实现上用一个零字节代替,因为"补零到分组长度"之后两者等价。

第二个细节的注释写得很到位:它解释的是"为什么一个零字节就够了",而不是"这里为什么要加个 1"。这种注释对后来人才有用。

点五:把系统 API 的怪行为挡在实现里

用系统能力省心,但系统 API 的边界行为得自己兜。这个实现里有四处很典型的处理:

① encodeInto 对空串返回 undefined(与声明的返回类型不符):

function utf8(value: string): Uint8Array {
  if (typeof value !== 'string') throw new TypeError('Expected UTF-8 string');
  // HarmonyOS encodeInto returns undefined for an empty string despite its declared return type.
  if (value.length === 0) return new Uint8Array(0);
  return encoder.encodeInto(value);
}

② CBC 解密"只有一个填充块"的密文时,系统返回 null DataBlob:

const result = await cipher.doFinal(input.length ? {data: input} : null);
if (result === null) {
  // A single CBC padding block decrypts to a null DataBlob in the system API.
  if (mode === crypto.CryptoMode.DECRYPT_MODE && !options.ctr && input.length === 16) return new Uint8Array(0);
  throw new Error('Crypto service returned no result');
}

注意这里不是把 null 一律当空——只在"CBC 解密且正好一个块"这个精确条件下才返回空,其他情况照旧抛错。把兜底条件收紧到最小,这是对的做法。

③ 解密后的字节要用严格模式解 UTF-8:

return util.TextDecoder.create('utf-8', {fatal: true}).decodeToString(result);

fatal: true 意味着非法 UTF-8 会抛错而不是替换成 �。所以"用错误的密钥解密"通常会明确失败,而不是返回一串乱码当成功。

顺带一条真机发现(写在库的检查报告里):旧写法 decodeWithStream 会把 NUL 字节替换成空格,导致含 NUL 的明文解密后"看起来对了但字节不对"。改用 decodeToString 后才逐字一致。这类问题只有拿含 NUL 的向量去测才会暴露——库自带向量里正好有 "Harmony\u0000\n鸿蒙" 这一条。

④ 明文里没有输入时不要调 update:空输入直接跳过 update,避免系统 API 在零长度输入上的边界行为。

另外,所有派生出来的密钥对象都在 finally 里显式清理:

} finally {key.clearMem();}

六、构建与运行

# 1) 装 JS 依赖 + 自动链接
npm install
./node_modules/.bin/react-native link-harmony

# 2) 生成调试签名 + 装 ohpm 依赖
cd harmony
devecocli signature generate
ohpm install --all

# 3) 打包 JS bundle(输出到 harmony/entry/src/main/resources/rawfile/)
cd ..
npm run dev

# 4) 编译 HAP
cd harmony
hvigorw --mode module -p product=default -p module=entry@default assembleHap --no-daemon

# 5) 安装 + 启动测试页
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
# 换页参数只在「冷启动」时生效:先 force-stop 再起(见坑四)
hdc shell aa force-stop com.rnoh084.demo
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey AesTestApp

耗时:在已有原生缓存的宿主上增量加入这个库,assembleHap 用了 6 分 38 秒。HAP 从 79.7 MB 涨到 80.0 MB,其中新库 + 两份向量 JSON 一共约 259 KB(向量 JSON 是会进 bundle 的,见坑六)。

如果宿主是全新 clone(没有原生编译缓存),首次构建会到 30–40 分钟量级。

看日志:

hdc shell "hilog -x | grep -i 'TM created'"
hdc shell "hilog -x | grep -i 'aes-test'"

看界面(读无障碍树,不用截图就能拿到文本):

devecocli ui layout

按文本点击(页面高度会随结果卡片变化,别记固定坐标):

. E:\rnoh-work\click-label.ps1
Click-Label -Pattern '一次跑完全部五组'

七、踩坑记录

坑一:HMAC 的密钥生成器要求固定长度

系统有两个拿到"HMAC 密钥"的入口,一个是带摘要名的 HMAC|SHA256,一个是通用 HMAC。带摘要名的那个要求密钥长度是固定值,空密钥和超长密钥都会失败。改用通用的 HMAC 密钥导入、并在实现里显式处理"空密钥补一个零字节"和"超长密钥先摘要"之后才通。

教训:系统 API 的两种入口看着等价,约束条件可能完全不同。测密钥长度的边界(0 / 1 / 20 / 64 / 128 / 129 / 4097 字节)是必要的——只用一个正常长度的密钥,这个问题永远不会暴露。

坑二:TextEncoder.encodeInto 对空串返回 undefined

与声明的返回类型不符,直接传给后续 API 会炸。空串在加密里是合法输入(库自带向量里就有 plain: "" 的三条 CBC 和三条 CTR),必须显式返回空字节数组。

坑三:CBC 空明文解密返回 null DataBlob

用正确的密钥解密"空明文加密出来的一整块填充",系统返回 null 而不是长度为 0 的数据。处理方式不能简单地把 null 当空——只在"CBC 解密 + 输入正好一个块"时返回空,其余仍然抛错。

坑四:rnAppKey 换页只在冷启动生效

RNOH084Demo 的换页机制是靠 EntryAbility.onCreate 里读 want.parameters['rnAppKey']。onCreate 只在 ability 冷启动时走一次,所以应用已经在运行时,再 aa start ... --ps rnAppKey <另一个页名> 只会把已有实例拉到前台,页面不会切——命令还报 start ability successfully,很容易被误导。

对策:换页前先强停,再带参数冷启动:

hdc shell aa force-stop com.rnoh084.demo
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey AesTestApp

坑五:link-harmony 不写模块级 oh-package.json5

执行 link-harmony 后日志写得很清楚:

• harmony/entry/src/main/cpp/RNOHPackagesFactory.h
• harmony/entry/src/main/cpp/autolinking.cmake
• harmony/entry/src/main/ets/RNOHPackagesFactory.ets
• harmony/oh-package.json5
info updated 4 file(s), linked 5 libraries, skipped 1 libraries

四个文件里没有 harmony/entry/oh-package.json5——它的 --oh-package-path-relative-to-harmony 参数默认只指向工程级那一份。而前面第四节说过,两级都要写 HAR,缺模块级那处就是"能找到包但链接不上"这种不好查的症状。

同一个坑还有个小兄弟:metro.config.js 的 watchFolders 要加新库的真实目录。file: 依赖装进 node_modules 之后是个链接(Windows 上是 Junction),不是真目录,不加就解析不到源码。

顺带一个可以自检的好信号:bundle 打包成功时,Metro 会打印它重定向到鸿蒙实现的三方包清单——

[INFO] Redirected imports to 5 harmony-specific third-party package(s):
[INFO] • expo-keep-awake → expo-keep-awake
…
[INFO] • react-native-aes-crypto → react-native-aes-crypto

这里没有你的库,就说明 autolinking 没认出来。

坑六:向量 JSON 会打进 bundle

真机上跑已知值向量很划算——一次装机就能验证上百项。但要清楚代价:那份 27.8 KB 的 vectors.json 加上本次新生成的向量,让 HAP 涨了约 259 KB。

如果向量规模再大一个量级,就该考虑只打包一个子集(比如每个算法留 3~5 条代表用例),把全量向量留在 PC 端的 node --test 里跑。

坑七:测试页的 UI 三件套(每个库都要注意)

  • 根节点要套 SafeAreaView——鸿蒙上 RN 导出的 SafeAreaView 本身是裸 View,需要 RNOH 的平台扩展文件才生效;不套的话内容和状态栏的时间/电量重合。
  • 别用固定坐标点击——结果卡片会随测试进度长高,按钮位置一直在动,要用文本定位。
  • 辅助 .ps1 只写 ASCII 注释——无 BOM 的 UTF-8 + 中文注释会被 Windows PowerShell 按 GBK 解码,吃掉行尾换行导致语法解析失败。

八、模拟器验证

验证环境:Pura X View 模拟器,HarmonyOS 7.0.0(26.0.0) Beta2,API 26,ohos-x64。

一个密码学库"能跑"没有任何意义——必须逐字节对上已知值。所以我把测试页做成了一个测试运行器:导入向量文件,逐项比对十六进制/Base64 输出,页面上直接给出通过数。

TurboModule 注册

RNInstance::TurboModuleProvider  TM created: Aes

在这里插入图片描述

设备侧结果:159 / 159 全部通过

[aes-test] 库自带向量 -> 77/77 全部通过
[aes-test] 独立交叉核验向量 -> 47/47 全部通过
[aes-test] 边界与格式 -> 9/9 全部通过
[aes-test] 随机接口 -> 12/12 全部通过
[aes-test] 非法输入 -> 14/14 全部通过

分组说明:

组来源断言数
① 库自带向量__tests__/vectors.json(47 条记录,AES 与 KDF 双向断言展开)77
② 独立交叉核验向量本次现场用 PC 端 Node/OpenSSL 生成,输入不与库自带重合47
③ 边界与输出格式内联构造9
④ 随机接口内联构造12
⑤ 非法输入内联构造14

在这里插入图片描述

第二层:PC 端独立复算库自带向量

设备侧说"通过"还不够——期望值本身也要可信。所以我在 PC 端用 Node.js 的 crypto(底层 OpenSSL)把库自带的每条向量重新算了一遍,完全不依赖设备:

独立复算:通过 71 项,失败 0 项

(71 = AES 24 条 × 双向 48 + KDF 6 + 摘要 9 + HMAC 8;设备侧是 77 项,因为 KDF 在设备上同步和异步各断言一次。)

这一层证明的是"期望值是对的",设备侧证明的是"鸿蒙实现和期望值一致",两层合起来才是完整的。

附带发现:向量里有 14 条标准文档官方向量

复算脚本顺带做了识别,结果这些向量并不是随手造的,其中不少是标准文档里的官方向量:

来源内容
RFC 6070PBKDF2-HMAC-SHA1,P="password" S="salt",c=1 与 c=4096
同族标准向量PBKDF2-HMAC-SHA256 / SHA512,c=1 与 c=4096
SHA 经典向量SHA1 / SHA256 / SHA512 对 "" 与 "abc"
RFC 4231 测试用例 1HMAC-SHA256 / HMAC-SHA512,key = 0x0b × 20,data = "Hi There"

这比"我自己造了 78 个期望值"可信得多——标准文档的向量是公开可查的,任何人都能拿这三行去核对。做适配时如果能为关键算法找到标准向量,验证的可信度会完全不同。

边界用例

输出格式(9/9):

  • CBC 输出是合法 Base64;CTR 输出是小写十六进制
  • CTR 密文长度 = 明文字节数 × 2(无填充)
  • 多块长文本、空串、CBC/CTR 往返一致
  • 默认导出与具名导出是同一个函数

随机接口(12/12):

  • randomKey(0) 返回空串(且不调用系统随机接口)
  • randomKey(1/16/32/64) 的十六进制长度正确、字符集正确
  • 两次 randomKey(32) 不相同
  • 8 个 UUID 全为小写 RFC 4122 v4 格式且互不重复
UUID 示例:82f7545b-5521-4763-91f6-9361e3d3036f

在这里插入图片描述

非法输入(14/14,全部拒绝,且拒绝后仍能正常加密):

算法非法 aes-256-ecb / 密钥非十六进制 zz / 密钥长度不匹配 00 / IV 不是 16 字节
CBC 密文非 Base64 / CBC 密文长度非 16 倍数 / CTR 密文非十六进制 gg
pbkdf2Sync cost=0 / length=257(非整字节)/ 摘要 md5
pbkdf2 cost=0 / randomKey(-1) / randomKey(1.5)

其中同步 API 必须同步抛(pbkdf2Sync),这是契约的一部分。最后一条"拒绝之后仍能正确加密"验证的是失败路径没有把状态搞坏。

在这里插入图片描述

能力对照

能力结果
encrypt / decrypt✅ 6 种算法 × 双向,含空串、整块、内嵌 NUL + 中文,全部对上已知值
pbkdf2 / pbkdf2Sync✅ 三种摘要 × cost 1/1000/4096,同步与异步结果一致,长度为位
hmac256 / hmac512✅ 密钥长度 0/1/20/64/128/129/4097 字节全部对上 OpenSSL
sha1 / sha256 / sha512✅ 含 "" / "abc" / 中文,全部对上标准向量
randomKey✅ 长度与十六进制格式正确,两次不相同
randomUuid✅ 小写 RFC 4122 v4,8 个互不重复
非法输入✅ 14 项全部拒绝;同步 API 同步抛
TurboModule 注册✅ TM created: Aes
合计✅ 159 / 159;PC 端独立复算 71 / 71

九、已知限制

一、CTR 与上游 Android 不兼容。 本实现对齐 iOS:CTR 无填充、密文为十六进制。上游 Android 的 CTR 用 Base64 + PKCS5Padding,两套协议不能互相解密,本包也不会自动识别或转换 Android 的历史密文。从 Android 版本迁移时,存量数据的处理要自己做。

二、CBC / CTR 本身不提供消息认证。 它们只保证机密性,不保证完整性。需要防篡改就得自己组合 HMAC(例如 encrypt-then-MAC)。本适配不改动上游协议,也不替你加这一层。

三、密钥位数必须与算法匹配,IV 必须显式传入。 实现会校验,不匹配直接拒绝——不会"自动补零"或"自动截断"。这是有意的:加密参数上的宽容就是安全问题。

四、randomKey 只保证长度与格式。 本次验证覆盖的是长度、十六进制字符集、两次不重复和 UUID 的 v4 结构;样本检查不等于证明随机源的熵质量。实际随机字节来自系统安全随机接口。

五、解密失败的处理依赖严格 UTF-8。 解密后的字节用 fatal: true 解 UTF-8,非法序列会抛错。好处是"用错密钥"通常明确失败;代价是二进制明文的解密结果无法用字符串返回——这个库的 API 就是字符串进字符串出(上游如此)。

六、大文件吞吐与高成本派生未测。 本次没有测大数据的加解密吞吐,也没有测 pbkdf2 在很高 cost 下的耗时。高迭代派生建议走异步接口,别用 pbkdf2Sync 阻塞 JS 线程。

七、其他 ROM / 真机未验证。 适配方记录的是 OpenHarmony-7.0.0.105;本次在 HarmonyOS 7.0.0(26.0.0) Beta2 模拟器上通过。

十、常见问题

Q:为什么不能直接 npm install react-native-aes-crypto?
A:npm 上那个同名包是上游的平台包,只有 iOS/Android 实现,没有鸿蒙原生代码。本仓库是独立的鸿蒙实现,要按 git+...#3.3.0-ohos-1.0.0 或者本地 file: 的方式装。

Q:需要额外依赖 expo-modules-core 吗?
A:不需要——这本来就不是 Expo 库,是按 RNOH 的 TurboModule + autolinking 规范直接实现的。

Q:我的 CBC 密文是 Base64,但 CTR 密文是十六进制,为什么不一样?
A:这是刻意对齐上游 iOS 的行为:CBC 用 PKCS7 填充、密文 Base64;CTR 不填充、密文十六进制。上游 Android 的 CTR 是 Base64 且用 PKCS5Padding,和这套不兼容。别指望两个平台的历史密文能互解。

Q:pbkdf2 的 length 到底传位还是字节?
A:位。这是上游的约定。传 256 得到 32 字节的密钥(64 个十六进制字符)。实现要求 length >= 8 且是 8 的整数倍——257 这种会被拒绝。

Q:pbkdf2Sync 会不会被改成 Promise?
A:不会,也不会应该改。上游的 pbkdf2Sync 是同步 API,C++ 侧用 ARK_METHOD_METADATA(同步)而不是 ARK_ASYNC_METHOD_METADATA 登记,这条契约是刻意保留的。但高迭代派生请用异步版,别在主线程上阻塞。

Q:为什么我用错误的密钥解密会直接抛错,而不是返回乱码?
A:因为解密结果用严格模式解 UTF-8(fatal: true),非法字节序列会抛错。所以大多数"错密钥"情况会明确失败。但反过来要注意:CBC 用错密钥时通常会在 PKCS7 去填充阶段就失败,这是填充校验带来的,不是消息认证——别把"解密成功"当成"数据没被篡改"。

Q:空字符串能加密吗?
A:能。CBC 会产出一整块填充的密文(Base64 里就是 24 个字符那种),CTR 产出空密文。库自带向量里专门有 plain: "" 的用例。

Q:HMAC 的密钥要传什么格式?长度有限制吗?
A:密钥是十六进制字符串(消息是 UTF-8 明文)。长度没有限制:空密钥按 HMAC 标准补零,超过分组长度(SHA256 是 64 字节、SHA512 是 128 字节)的密钥会先做一次摘要——这两条都是标准行为。本次验证覆盖了 0/1/20/64/128/129/4097 字节的密钥。

Q:怎么确认这个适配是真的对,而不是"调用没报错"?
A:三层证据:① 设备侧 159 项已知值断言全部通过(十六进制/Base64 逐字节比对);② PC 端用 Node/OpenSSL 独立复算库自带向量 71 项,0 失败(证明期望值本身可信);③ 向量里有 14 条是 RFC 6070 / RFC 4231 / SHA 经典官方向量,任何人都能拿标准文档核对。密码学库的验证不能停在"接口能调通"。

Q:为什么我在模拟器上编译要这么久?
A:看是不是首次编译。已有原生缓存的宿主增量加一个库是 6–8 分钟;全新 clone 的宿主首次编译要 30–40 分钟。而且只改 JS 重新打包也要 6–7 分钟(hvigor 会把整套流水线走一遍),所以别把 UI 微调留到最后做。

小结

这个库的接入本身很轻——C++ 侧只有 39 行注册壳,密码学全部交给系统 cryptoFramework。真正花时间的地方有两块:

第一块是把"格式与契约"钉死。 上游 iOS 和 Android 自己就不一致(CTR 的填充与编码),我选择对齐 iOS 并在 README 里明确写出"不能互解";pbkdf2 的长度单位是位不是字节、pbkdf2Sync 必须保持同步、密钥位数必须匹配算法——这些"接口契约"如果含糊,调用方迁移时一定会踩。

第二块是把系统 API 的怪行为一个个挡掉。 encodeInto 对空串返回 undefined、CBC 单块解密返回 null、decodeWithStream 会把 NUL 换成空格、HMAC 密钥生成器对长度有隐含要求——这些都是真机上撞出来的。写适配时边界输入(空串、空密钥、超长密钥、内嵌 NUL)不是可选项,它们才是暴露问题的用例。

验证方法上,这次是几篇里最"硬"的一次,因为它可验证:

  • 密码学库的输出是确定性的,所以"跑通"没有意义,必须逐字节对已知值;
  • 期望值本身也要独立来源——库自带的向量我在 PC 端用 Node/OpenSSL 重算了一遍(71 项,0 失败),另外又**现场生成了一组输入不重合的新向量(47 项)**在设备上跑;
  • 能找标准向量就用标准向量——这次核出 14 条 RFC 6070 / RFC 4231 / SHA 官方向量,比"我自己算的期望值"可信得多。

适配链路本身,还是那几条老规律:autolinking 的四个身份名少一个都是"编译过了但模块没注册";HAR 的工程级与模块级双重声明,而且 link-harmony 只会自动写工程级那一份;版本四件套必须对齐,且以实测组合为准。

最后是一句方法论上的话:这个库之所以能给出 159/159 这样的结论,不是因为我测得多,而是因为它本来就"可验证"。 换成一个渲染类或效果类的库,同样的力气换不来同样的确定性——那种情况就得靠截图、靠拉回产物肉眼核对(这正是前面几篇的教训)。先判断一个库"能被验证到什么程度",再决定验证怎么设计,比埋头跑用例更重要。


本篇用到的库

项内容
三方库react-native-aes-crypto(上游 3.3.0 的鸿蒙适配版)
适配仓库https://atomgit.com/oh-react-native/react-native-aes-crypto
适配 TAG3.3.0-ohos-1.0.0
ohpm 包名@react-native-ohos/react-native-aes-crypto
HARharmony/aes.har(4.6 KB)
基线 commit5ee374cd2dbe94c413bee7561b1e27a910ce8ee4
宿主工程RNOH084Demo(测试页 rnAppKey = AesTestApp)
"react-native-aes-crypto": "git+https://atomgit.com/oh-react-native/react-native-aes-crypto.git#3.3.0-ohos-1.0.0"
// harmony/oh-package.json5 与 harmony/entry/oh-package.json5 都要加
"@react-native-ohos/react-native-aes-crypto":
  "file:../node_modules/react-native-aes-crypto/harmony/aes.har",
# 换页启动测试页(force-stop 不能省,换页参数只在冷启动生效)
hdc shell aa force-stop com.rnoh084.demo
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey AesTestApp
import Aes from 'react-native-aes-crypto';

const key = await Aes.randomKey(32);        // 32 字节 → 64 个十六进制字符
const iv = await Aes.randomKey(16);         // IV 必须是 16 字节
const encrypted = await Aes.encrypt('HarmonyOS', key, iv, 'aes-256-cbc');   // → Base64
const original = await Aes.decrypt(encrypted, key, iv, 'aes-256-cbc');
const derived = await Aes.pbkdf2('password', 'salt', 4096, 256, 'sha256');  // length 单位是位
const mac = await Aes.hmac256('message', key);                              // key 是十六进制

验证环境

项版本
React Native0.84.1
React19.2.3
RNOH(npm / ohpm)@react-native-oh/react-native-harmony / @rnoh/react-native-openharmony 0.84.3
Node.jsv24.14.0
DevEco Studio26.0.0.621
HarmonyOS SDKAPI 26(26.0.0.32)
设备HarmonyOS 7.0.0(26.0.0) Beta2 模拟器 Pura X View(ohos-x64)
宿主 HAP 产物entry-default-signed.hap(80.0 MB)
本次增量构建assembleHap 6 分 38 秒
验证规模设备侧 159 项断言全通过;PC 端独立复算 71 项 0 失败

欢迎加入 CPF-RN 鸿蒙社区:https://atomgit.com/CPF-RN

React Native for OpenHarmony 组织:https://atomgit.com/oh-react-native

RN 三方库鸿蒙适配清单:https://atomgit.com/oh-react-native/rn-ohos-adaptation-overview

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐