本文记录 bcrypt.js 适配仓颉(Cangjie)的完整过程。适配目标是得到一个可以被仓颉项目直接依赖的跨端三方库,并在本地终端中通过真实 bcrypt 计算验证盐值生成、哈希、密码比较、轮数和盐提取、72 字节截断检测以及 bcrypt Base64 编解码能力。
项目地址: AtomGit/nutpi_cj/bcrypt.js
上游仓库: dcodeIO/bcrypt.js
开发工具: 华为云码道
一、背景
1.1 为什么适配 bcrypt.js
bcrypt.js 是一个纯 JavaScript 的 bcrypt 实现,兼容常见的 bcrypt 哈希格式,不依赖原生扩展,适用于 Node.js 和浏览器。它提供盐值生成、同步和异步风格的哈希、密码比较、轮数提取、盐提取、密码长度截断检测以及 bcrypt 专用 Base64 编解码。
JavaScript 版本依赖动态参数、字符串和字节数组之间的运行时转换。salt 参数既可以是轮数,也可以是完整盐字符串;随机数回退还需要传入函数。仓颉强调静态类型、显式异常和编译期检查,因此本适配使用函数重载区分 Int64 轮数与 String 盐值,使用 Array 表达二进制数据,并将非法参数统一转换为仓颉异常。
bcrypt 的核心不是简单的摘要函数,而是 Blowfish 密钥编排和按 2 的 cost 次方重复的计算过程。适配必须保留 P-array、S-box、EksBlowfish 和 bcrypt 自定义 Base64 的真实算法,否则只能得到"接口存在"的空壳,无法与原库生成的哈希互相验证。
1.2 跨端三方库的适配边界
当前项目提供的是仓颉跨端库,不绑定某一个 UI 框架或操作系统。适配边界可以概括为:
| 边界 | 仓颉实现 |
|---|---|
| 包管理 | cjpm.toml、cjpm build 和静态库输出 |
| bcrypt 核心 | Blowfish P-array、S-box、EksBlowfish 和 64 次加密 |
| 输入类型 | String 密码、Int64 轮数、String 盐值、Array 字节 |
| 随机盐 | std.random.Random 和 setRandomFallback |
| 编码 | bcrypt 专用 Base64 encodeBase64/decodeBase64 |
| 元数据 | getRounds、getSalt、truncates |
| 交付形态 | 仓颉静态库 libbcryptjs_cangjie.a 和接口文件 bcryptjs_cangjie.cjo |
| 验证方式 | test/main.cj 静态库测试和 example/example.cj 终端示例 |
这里没有 HAP 构建、ArkTS 桥接和真机系统能力验收。当前 Cangjie API 采用同步调用模型:genSalt、hash 和 compare 保留原库名称,但返回直接结果;JavaScript Promise、回调和进度回调没有伪装成已经支持的仓颉异步接口。只要目标平台提供兼容版本的仓颉 SDK 和 std.random,就可以按同一套源码和 cjpm 配置构建并消费这个库。
1.3 适配后的能力
当前导出的仓颉 API 共 12 个顶层公开名称,覆盖以下能力:
- setRandomFallback:设置生成盐值时使用的自定义字节生成器;
- genSaltSync 和 genSalt:按默认或指定 cost 生成 bcrypt 盐值,cost 为 0 时沿用默认 10,其他值限制在 4 到 31;
- hashSync 和 hash:使用数字 cost 或完整盐值生成 60 字符 bcrypt 哈希;
- compareSync 和 compare:以常量时间方式比较正确和错误密码;
- getRounds 和 getSalt:读取哈希中的 cost 和 29 字符盐值前缀;
- truncates:检查 UTF-8 密码是否超过 bcrypt 的 72 字节输入上限;
- encodeBase64 和 decodeBase64:处理 bcrypt 使用的非标准 Base64 字母表。
二、实现路线图
text
第 1 阶段:盘点 API ── 对照 bcrypt.js API,确定仓颉重载、异常和算法边界
第 2 阶段:建立 cjpm 工程 ── 固定包名、版本、源码目录和静态库输出
第 3 阶段:类型映射 ── 用 String、Int64、Array<UInt8> 和函数类型替换动态值
第 4 阶段:实现 bcrypt ── 完成自定义 Base64、Blowfish 密钥编排和哈希格式
第 5 阶段:测试与核对 ── 用标准已知向量、错误密码和边界输入验证结果
第 6 阶段:终端演示 ── 直接输出盐值、哈希、比较和编码结果,生成效果图
每个阶段都有可检查的产物:工程阶段得到 cjpm.toml,实现阶段得到 .cjpm-target/bcryptjs_cangjie,验证阶段得到 ALL BCRYPT CANGJIE TESTS PASS,最终示例则输出来自真实仓颉库调用的全部结果。
三、逐步实现过程
第 1 阶段:盘点原库 API 和仓颉工程边界
1.1 识别动态 API
bcrypt.js 的 JavaScript 入口把 salt 定义为 number 或 string,hash 和 compare 还同时提供 Promise 与 callback 形式。仓颉没有必要保留一个运行时判断的无类型参数,因此当前版本把这些调用拆成静态重载:
cangjie
let salt = "\$2b\$04\$......................"
let fixedHash = hashSync("password", salt)
let generatedHash = hashSync("password", 4)
let same = compareSync("password", fixedHash)
String 参数表示完整 bcrypt 盐值,Int64 参数表示 cost。非法版本、轮数、盐长度和 Base64 字符会抛出 IllegalArgumentException,调用方可以用仓颉异常机制处理错误。
1.2 固定仓颉项目结构
以下目录树按照当前 bcrypt.js 项目的仓颉适配文件整理,只展示仓颉相关文件;原始 JavaScript、TypeScript、npm 测试和命令行脚本保持原位置:
text
bcrypt.js/
├── cjpm.toml
├── cjpm.lock
├── README.Cangjie.md
├── README.Cangjie_CN.md
├── src/
│ └── bcrypt.cj
├── test/
│ └── main.cj
└── example/
├── cjpm.toml
├── cjpm.lock
└── example.cj
源码集中在 src/bcrypt.cj,测试入口位于 test/main.cj,示例工程位于 example/。示例的 src-dir 是当前目录,但 target-dir 放在仓库根目录的 .cjpm-target/bcryptjs_example,因此构建输出不会被当作源码再次扫描,也不会出现"there is no '.cj' file ... target"警告。
第 2 阶段:创建 cjpm 工程
根目录 cjpm.toml 使用静态库输出类型,并把构建目录放到 .cjpm-target:
toml
[package]
cjc-version = "1.1.3"
name = "bcryptjs_cangjie"
version = "0.0.0"
output-type = "static"
src-dir = "src"
target-dir = ".cjpm-target/bcryptjs_cangjie"
示例工程使用本地路径依赖,并把构建目录放到 example 外部:
toml
[package]
cjc-version = "1.1.3"
name = "bcryptjs_example"
version = "1.0.0"
output-type = "executable"
src-dir = "."
target-dir = "../.cjpm-target/bcryptjs_example"
[dependencies]
bcryptjs_cangjie = { path = ".." }
这样运行 example/cjpm.toml 对应的 cjpm run 时,构建输出不会进入 src-dir = "." 的扫描范围。验证结束后删除 .cjpm-target,构建产物不提交到仓库。
构建成功后,根包最重要的两个产物是:
text
.cjpm-target/bcryptjs_cangjie/release/bcryptjs_cangjie/libbcryptjs_cangjie.a
.cjpm-target/bcryptjs_cangjie/release/bcryptjs_cangjie/bcryptjs_cangjie.cjo
其中 .a 用于链接,.cjo 提供编译器需要的接口信息。
第 3 阶段:把 JavaScript 动态值映射为仓颉类型
3.1 密码、盐值和字节数组
密码和哈希在仓颉中使用 String,UTF-8 编码后的密码和盐值使用 Array。数字 cost 使用 Int64,哈希输出和盐值输出继续保持 String:
cangjie
let salt = "\$2b\$04\$......................"
let hash = hashSync("password", salt)
println(hash)
println(compareSync("password", hash))
原库的 bcrypt 输出固定为 60 个字符,盐值前缀固定为 29 个字符。Cangjie 实现通过 getSalt 和 getRounds 读取这些结构,而不是让调用方手动切割字符串。
3.2 用函数类型表达随机回退
JavaScript setRandomFallback 接收一个返回随机字节的函数。仓颉版本使用明确函数类型:
cangjie
let fallback = { length: Int64 =>
Array<UInt8>(length, { i => UInt8((i * 17 + 3) % 256) })
}
setRandomFallback(fallback)
let deterministicSalt = genSalt(4)
测试中的回退生成器只用于验证输出稳定性。生产环境必须提供经过安全播种的密码学安全随机源,不能把示例中的确定性函数用于真实密码盐值。
3.3 用重载保持 salt 的语义
salt 的数字和字符串两种语义分别对应不同重载:
cangjie
let byRounds = hashSync("password", 4)
let bySalt = hashSync("password", "\$2b\$04\$......................")
genSaltSync、genSalt、hashSync、hash、compareSync 和 compare 都保持清晰的返回类型。同步函数直接返回 String 或 Bool;非法参数通过异常返回,避免使用 null 或无类型结果隐藏失败原因。
第 4 阶段:实现源码
4.1 文件 src/bcrypt.cj
源码按 bcrypt 生命周期组织:先把密码转换为 UTF-8 字节,再解析版本、minor revision、cost 和 16 字节盐值;随后初始化 Blowfish P-array 与 S-box,执行 EksBlowfish,按 2 的 cost 次方交替执行 password key schedule 和 salt key schedule,最后对固定的 OrpheanBeholderScryDoubt 数据执行 64 轮加密并编码为 23 字节结果。
32 位 Blowfish 运算使用 Int64 承载中间值,并通过 add32 截断到 32 位,保证 Cangjie 的溢出检查不会改变 bcrypt 的模 2^32 运算。P-array 有 18 个元素,S-box 有 1024 个元素,常量直接来自原库的 Blowfish 初始化表。
4.2 bcrypt 专用 Base64
bcrypt 的 Base64 字母表不是 MIME Base64,而是:
text
./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789
encodeBase64 和 decodeBase64 使用这套字母表处理盐值和最终摘要。固定输入的真实结果为:
text
encodeBase64([0, 1, 2, ..., 16], 16)
=> ..CA.uOD/eaGAOmJB.yMBu
decodeBase64("..CA.uOD/eaGAOmJB.yMBv.", 16)
=> 16 bytes, first=0, last=15
这一步决定生成的盐值和哈希是否能与 bcrypt.js 互相识别,不能替换为标准 Base64。
4.3 盐值、轮数和截断检测
genSaltSync 会把 cost 限制在 bcrypt 支持的 4 到 31 范围;传入 0 时使用默认 cost 10。truncates 按 UTF-8 字节数判断 72 字节限制,而不是按仓颉字符串的字符数判断:
cangjie
truncates(repeatedA(72)) // false
truncates(repeatedA(73)) // true
getRounds(fixedHash) // 4
getSalt(fixedHash) // $2b$04$......................
compareSync 先检查哈希长度,再用提取的盐值重新计算密码,最后逐字符累积差异完成比较。
第 5 阶段:建立自动化测试
test/main.cj 是一个直接链接仓颉静态库的可执行测试入口,覆盖以下行为:
| 测试组 | 示例 |
|---|---|
| 已知向量 | cost 4、固定盐值和 password 的标准结果 |
| 哈希和比较 | 正确密码为 true,错误密码为 false |
| API 重载 | 数字 cost、完整盐值和 hash/compare 别名 |
| 元数据 | getRounds、getSalt |
| 输入边界 | 72 字节不截断、73 字节会截断、cost 0 使用默认值 |
| Base64 | bcrypt 字母表编码和解码 |
| 随机回退 | 自定义生成器产生稳定盐值 |
固定盐值的结果与 JavaScript bcrypt.js 相同:
text
$2b$04$......................LAtw7/ohmmBAhnXqmkuIz83Rl5Qdjhm
全部测试通过时输出:
text
ALL BCRYPT CANGJIE TESTS PASS
第 6 阶段:核对 bcrypt.js 适配 API
对 src/bcrypt.cj 的顶层公开声明进行核对,当前仓颉适配公开名称为 12 个:
text
setRandomFallback
genSaltSync
genSalt
hashSync
hash
compareSync
compare
getRounds
getSalt
truncates
encodeBase64
decodeBase64
其中 genSaltSync、genSalt、hashSync 和 hash 存在参数重载,但没有引入未实现的 Promise、callback 或 progressCallback。这个检查说明当前公开入口和文档一致,不代表仓颉版本覆盖 JavaScript bcrypt.js 的全部异步运行时语义。
四、完整验证步骤
以下命令均以仓库根目录 bcrypt.js/ 为当前目录。先确认本机已经安装仓颉 SDK:
bash
cjc --version
cjpm --version
然后构建静态库:
bash
cjpm build
用生成的静态库编译并运行 test/main.cj:
bash
cjc test/main.cj \
--import-path .cjpm-target/bcryptjs_cangjie/release/bcryptjs_cangjie \
-L .cjpm-target/bcryptjs_cangjie/release/bcryptjs_cangjie \
-l bcryptjs_cangjie \
--static \
-o /tmp/bcryptjs-cangjie-test
/tmp/bcryptjs-cangjie-test
成功时会看到每项 PASS,并以 ALL BCRYPT CANGJIE TESTS PASS 结束。
再构建并运行独立的终端示例:
bash
cd example
cjpm build
cjpm run
示例使用 target-dir = ".../.cjpm-target/bcryptjs_example",因此不会把构建目录当作源码扫描目录。验证完成后回到仓库根目录清理构建产物:
bash
cd ..
find .cjpm-target -type f -delete
find .cjpm-target -type d -depth -empty -delete
五、运行效果
下面的效果图来自 example/example.cj 的实际终端运行,包含 bcrypt.js Cangjie 静态库构建后的真实哈希、比较、元数据、截断检测和 Base64 结果。

终端中的 hashSync、hash、compareSync、compare、getRounds、getSalt、truncates、encodeBase64、decodeBase64、genSaltSync、setRandomFallback 和 genSalt 均由仓颉库实际调用产生。固定盐值的哈希可与 JavaScript bcrypt.js 结果逐字比对,随机盐值的内容会随运行变化,比较结果仍然为 true。
六、关键决策说明
6.1 为什么保留完整 bcrypt 核心
bcrypt 不是普通的单次哈希,cost 直接决定密钥编排重复次数。如果只实现盐值格式或只返回预先写好的示例字符串,无法验证密码存储功能,也无法与既有 bcrypt 数据兼容。因此仓颉版本保留完整 Blowfish 常量、EksBlowfish、cost 循环和 23 字节摘要编码,测试使用固定已知向量校验算法结果。
6.2 为什么保留 bcrypt.js 函数名称
保留 setRandomFallback、genSaltSync、hashSync、compareSync、getRounds、getSalt、truncates、encodeBase64 和 decodeBase64 等名称,可以降低从 JavaScript bcrypt.js 迁移的成本。仓颉调用方只需要适应参数重载、Array 和异常处理,不必重新记忆一套完全不同的密码 API。
genSalt、hash 和 compare 也保留为同步入口,便于在仓颉中使用原库熟悉的名称;Promise、callback 和 progressCallback 在当前适配范围之外,并在文档中明确说明。
6.3 为什么把测试和示例分开
测试程序负责固定向量和边界断言,适合自动化执行;示例工程负责展示真实盐值、哈希字符串和比较结果,适合在终端观察并生成截图。两者都使用同一个 bcryptjs_cangjie 静态库,因此效果图不是模拟页面,而是库的实际运行输出。
七、兼容边界和遗留问题
JavaScript bcrypt.js 与仓颉运行时模型不同,以下限制需要在接入时提前了解:
- JavaScript 的 Promise、callback 和 progressCallback 异步 API 尚未映射;当前 genSalt、hash 和 compare 是同步返回结果的仓颉入口。
- 密码先转换为 UTF-8 字节,bcrypt 的有效输入上限仍是 72 字节;多字节字符可能比字符数量占用更多字节,应使用 truncates 检查。
- Cangjie 版本使用 Int64 表示 cost,合法范围为 4 到 31;cost 越高,计算时间和资源消耗呈指数增加,示例使用 4 只为快速演示。
- 默认随机盐使用 std.random.Random;setRandomFallback 只适合接入调用方提供的密码学安全随机源,示例中的确定性回退函数不能用于生产环境。
- 支持 bcrypt.js 兼容的 2 a 2a 2a、 2 b 2b 2b 和 2 y 2y 2y 盐版本解析;不支持的版本、错误长度和非法 Base64 输入会抛出 IllegalArgumentException。
- 本库只负责密码哈希和比较,不提供密码数据库、用户认证流程、密钥管理、登录限流或 HAP/ArkTS 集成能力。
这些不是构建错误。只要 cjpm build 成功、固定向量测试输出 ALL BCRYPT CANGJIE TESTS PASS、示例中生成哈希后的 compare generated hash 为 true,就说明当前版本已经完成本地编译和行为验收。
八、接入方式
在另一个仓颉项目中,可以将本仓库作为本地依赖或 AtomGit 依赖使用。远程依赖配置可以写成:
toml
[dependencies]
bcryptjs_cangjie = { git = "https://atomgit.com/nutpi_cj/bcrypt.js.git", branch = "main" }
本地验证时,也可以使用:
toml
[dependencies]
bcryptjs_cangjie = { path = ".." }
调用代码只需要导入:
cangjie
import bcryptjs_cangjie.*
main(): Int64 {
let salt = "\$2b\$04\$......................"
let hash = hashSync("password", salt)
println(hash)
println(compareSync("password", hash))
println(getRounds(hash))
return 0
}
如果需要生成真实随机盐值,直接调用 genSaltSync(10) 或 genSalt(10);如果目标平台需要接管随机数来源,可以先调用 setRandomFallback。跨端项目可以复用相同的盐值、哈希和比较 API,认证存储、密钥管理和 UI 展示由宿主应用负责。
九、总结
本次适配完成了 bcrypt.js 到仓颉静态类型模型的迁移:密码和哈希使用 String,二进制数据使用 Array,cost 使用 Int64,随机回退使用带类型的函数值,Blowfish P-array、S-box、EksBlowfish、bcrypt Base64 和 60 字符哈希格式均由 src/bcrypt.cj 真实实现。
验证链路包括 cjpm build、静态库链接测试、标准 bcrypt 已知向量、正确和错误密码比较、元数据提取、UTF-8 截断判断、Base64 编解码以及独立示例输出。最终效果图来自真实的仓颉可执行程序,能够直观看到三方库生成的盐值、哈希和验证结果。对于 Promise、callback、progressCallback 和宿主认证系统等无法一比一映射的部分,项目通过静态签名和文档边界保持行为可解释,方便后续扩展。
适配过程 FAQ
以下问题来自仓颉工程创建、
cjpm依赖、与 HarmonyOS 设备联调的实际流程。
Q1:cjpm 依赖解析失败或拉取超时?
- 检查
cjpm.toml中的依赖坐标与版本号; - 配置可用的仓颉包仓库镜像或代理;
- 删除
target、cjpm.lock后重新执行cjpm update/cjpm build。
Q2:编译报找不到仓颉 SDK 或 cjc 版本不匹配?
- 设置
CANGJIE_HOME指向仓颉 SDK 根目录; - 确认
cjc --version、cjpm --version与工程要求的版本一致; - 清理构建缓存后重新生成。
Q3:与 HarmonyOS 设备联调时安装失败?
- 用
hdc list targets确认设备在线; - 确认 HAP 已签名,
bundleName与签名 profile 一致; - 设备需加入调试 profile 的
deviceIds。
Q4:动态库或 FFI 符号找不到?
- 检查导出符号名称、ABI 与打包路径;
- 确认
.so已随 HAP 一起打包到libs/<abi>/; - 用
llvm-readelf -d检查依赖库是否齐全。
Q5:测试用例在设备上失败,但本机通过?
- 对比设备与主机的架构、SDK 版本和环境变量;
- 用
hdc hilog抓取运行日志; - 优先复现最小用例,确认是否为平台差异或时序问题。