MooTool Compose 版 F14 加解密工具:基于 JCA + BouncyCastle 1.80 实现 Electron 字节级兼容的加密引擎与验收记录

原创2026-10-15 15:46:171,748 阅读
文章标签:开发工具桌面应用MCP 服务

MooTool Compose 版 F14 加解密工具:基于 JCA + BouncyCastle 1.80 实现 Electron 字节级兼容的加密引擎与验收记录

本文基于 MooTool 仓库中 Compose Multiplatform 产品(next-compose/ 工作树)的 F14 加解密与随机工具验收记录展开,介绍该工具以 Java JCA + BouncyCastle 1.80 为底座,如何在 AES/DES/SM4 对称加解密、RSA/SM2 非对称加解密与签名、摘要/Base 编码与随机生成等场景下做到与 Electron 版密文字节级兼容,以及其「码点截断/补零后按 UTF-8 字节校验」的密钥规范化策略与 72/72 测试通过的验收结论。读完本文,你可以了解如何在 JVM 生态中复刻一套带国密算法的工具级加密引擎,并掌握其密钥规则、C1C3C2 密文兼容逻辑与边界条件处理。

验收记录原文与执行环境

F14 的验收记录保存在 results.md,其核心结论如下:

  • 阶段/条目:F14 加解密与随机(P4 媒体/加密首个工具);
  • 产品工作树:仅 next-compose/;
  • 依赖:JCA + org.bouncycastle:bcprov-jdk18on 1.80(SM2/SM3/SM4);
  • 参考基线:Electron 的 cryptoTools.ts 与 CryptoTool.tsx,架构为 JCA/BC;
  • 执行方式:JAVA_HOME 指向 Zulu 21.0.12.1,运行 ./gradlew :composeApp:desktopTest,72/72 测试通过(其中 CryptoEngine 5 项;此前 F07 基线为 67/67,本轮新增 5 项);
  • 未测项:窗口截图、256 MiB 以上文件、哈希中途取消、安装镜像、与 Java Hutool 密文的互操作(刻意不兼容);
  • 下一轮:P4 其余条目(F17 二维码、F22 调色板、F19 留言板、F23 图片、F24 PDF)。

依赖声明可在 libs.versions.toml 中确认:bouncyCastle = "1.80",对应库坐标为 org.bouncycastle:bcprov-jdk18on。SM2、SM3、SM4 这三类国密算法在 JCA 标准提供者中不存在,必须依赖 BouncyCastle 提供者;而 CryptoEngine 在静态初始化块中显式注册了该提供者(见 CryptoEngine.kt):

init {
    if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
        Security.addProvider(BouncyCastleProvider())
    }
}

这一写法保证了即使运行环境(或宿主应用)尚未注册 BC,加密调用也不会因为找不到 SM4/SM3 算法名而失败。

功能语义全景:五类能力与固定验收样本

验收记录中的「语义」一行概括了 F14 覆盖的完整能力面,逐项对应到 CryptoEngine.kt 中的公开 API:

能力域 验收语义 源码入口
对称加解密 AES/DES/SM4,ECB + PKCS#7 填充,Hex 小写密文 symmetricEncrypt / symmetricDecrypt
非对称(RSA) PKCS#1 填充加解密、私钥加密/公钥解密「私钥操作」、SHA-256 签名 asymmetricEncrypt / asymmetricDecrypt / privateEncrypt / publicDecrypt / signContent
非对称(SM2) C1C3C2 模式加解密、DER 格式签名,消费 Electron 样本 同上,算法分支为 SM2
摘要 MD5/SHA-1/SHA-256/SHA-384/SHA-512/SM3,文本与文件两种输入 digestText / digestFile
Base 编码 Base64/Base32 编解码 encodeBase / decodeBase
随机生成 UUID、纯数字、字符串、密码(四类字符集强制覆盖) randomUuid / randomDigits / randomString / randomPassword
会话能力 历史与分离窗口(UI 层,CryptoSession) SessionManager.kt 中的 CryptoSession

引擎内置了一组固定验收常量,测试与 UI 都围绕它们展开:

const val SAMPLE_PLAIN = "MooTool"
const val SAMPLE_KEY = "1234567890abcdef"
const val MAX_TEXT_BYTES = 2 * 1024 * 1024      // 文本输入 2 MiB 上限
const val MAX_FILE_BYTES = 256L * 1024L * 1024L  // 文件摘要 256 MiB 上限
const val MIN_RANDOM_LENGTH = 1
const val MAX_RANDOM_LENGTH = 4096
private const val RSA_BITS = 2048
private val sm2UserId = "1234567812345678".toByteArray(Charsets.UTF_8)

这些常量定义了工具的边界条件:文本超过 2 MiB、文件超过 256 MiB 会抛出 too-large 错误码的 CryptoException;随机长度限定在 1~4096;RSA 默认生成 2048 位密钥;SM2 签名使用国密规范默认的 16 字节用户标识(UID)——这是与 Electron 版 sm-crypto 保持一致的关键参数。

密钥规范化:码点截断/补零,再按 UTF-8 字节拒绝

F14 验收记录中最值得展开的差异点是 DIFF-008(非 ASCII 密钥拒绝;不兼容 Java Hutool generateKey),完整差异说明见 008-crypto-key-bytes.md。

规则本身

Electron 版(CryptoJS 路径)的密钥处理是:按 Unicode 码点截断或右侧补字符 0,然后对结果做 UTF-8 编码作为密钥字节。Compose 版在 normalizeKey 中逐码点复刻了这一行为:

internal fun normalizeKey(key: String, length: Int): String {
    val points = key.codePoints().toArray()
    val selected = if (points.size >= length) {
        points.copyOf(length)
    } else {
        IntArray(length).also { padded ->
            points.copyInto(padded)
            for (index in points.size until length) padded[index] = '0'.code
        }
    }
    return String(selected, 0, selected.size)
}

例如 DES 传入 abc(3 个码点)会被补成 abc00000(8 码点);AES 传入 20 位十六进制串只取前 16 个码点。随后在 symmetricKey 中按字节做最终校验:

val normalized = normalizeKey(key, charLength)
val bytes = normalized.toByteArray(Charsets.UTF_8)
val expected = if (algorithm == SymmetricAlgorithm.DES) 8 else 16
if (bytes.size != expected) {
    throw CryptoException(
        "invalid-key",
        "Key UTF-8 length is ${bytes.size} after character trim/pad to $charLength; $algorithm requires $expected bytes"
    )
}

这里体现了该工具的明确设计取舍:码点长度合格但 UTF-8 字节变长时直接拒绝,而不是静默截断字节。典型反例是 16 个汉字——码点数恰好 16,但 UTF-8 编码后是 48 字节,因此抛 invalid-key。这一点在测试中有直接断言(CryptoEngineTest.kt):

val cjkKey = "你好世界你好世界你好世界你好"
val error = assertFailsWith<CryptoException> {
    CryptoEngine.symmetricEncrypt(SymmetricAlgorithm.AES, "Moo", cjkKey)
}
assertEquals("invalid-key", error.code)

同时 symmetricKeyUtf8Length 提供了 UI 侧的实时反馈模型 SymmetricKeyUtf8Length(current, required),界面上以 {current}/{required} 的形式展示当前 UTF-8 字节数与要求值(DES 8、AES/SM4 16),让用户在输入过程中就能看到密钥是否会被拒绝。验收文档 acceptance.md 也登记了这一行为归属 DIFF-524 与 DIFF-008。

为什么不走 Hutool 路径

Java 生态里更常见的做法是 Hutool 的 SecureUtil.generateKey:把任意长度口令派生(hash 后取用)为算法密钥。Compose 版刻意不采用该路径,原因是规格要求以 Electron 为兼容基线——同一份密钥字符串在 Electron 与 Compose 之间必须产生相同密文。代价是 Java 版 Hutool 生成的密文不能直接作为 Compose 的对称输入(除非密钥恰好是 ASCII 且未走 generateKey),这一影响范围在 DIFF-008 的「受影响范围」一节中已明确记录。

对称算法:AES/DES/SM4 统一走 ECB + PKCS5Padding

symmetricCipher 把三个算法映射到 JCA 的转换字符串:

private fun symmetricCipher(algorithm: SymmetricAlgorithm): Cipher = when (algorithm) {
    SymmetricAlgorithm.AES -> Cipher.getInstance("AES/ECB/PKCS5Padding")
    SymmetricAlgorithm.DES -> Cipher.getInstance("DES/ECB/PKCS5Padding")
    SymmetricAlgorithm.SM4 -> Cipher.getInstance("SM4/ECB/PKCS5Padding", BouncyCastleProvider.PROVIDER_NAME)
}

注意 ECB 模式在工程上属于弱模式(相同明文块产生相同密文块),这里选择它是为了与 Electron 版 CryptoJS 的 ECB + PKCS7 语义保持字节兼容——JCA 的 PKCS5Padding 对 8/16 字节分组算法与 PKCS#7 等价。解密侧对失败做了统一收敛:doFinal 抛错(填充校验失败等)与「解密结果为空字符串」两种情形都归一到 CryptoException("decrypt", "Unable to decrypt with the supplied key"),与 Electron 的错误文案一致。密文输出为小写 Hex(toHex 用 toString(16).padStart(2, '0') 实现),输入侧 parseHex 允许任意空白字符、要求偶数长度且逐对校验 [0-9a-fA-F]{2},非法时抛 invalid-hex。

非对称:RSA 双格式解析与 SM2 的 C1C3C2 兼容

RSA:PKCS#1 优先,兼容 X.509/PKCS#8

RSA 默认生成 2048 位密钥(RSA_BITS),公钥编码为 PKCS#1 DER 的 Base64(通过 org.bouncycastle.asn1.pkcs.RSAPublicKey(modulus, publicExponent).encoded 直接构造 ASN.1 结构),私钥同理走 RSAPrivateKey 结构。解析侧 parseRsaPublic / parseRsaPrivate 采用「先试 PKCS#1,再试标准编码」的双通道策略:

  • 公钥:先按 PKCS#1 解析失败后回退 SubjectPublicKeyInfo(X.509)+ X509EncodedKeySpec,两者都失败抛 invalid-key;
  • 私钥:先按 PKCS#1(RSAPrivateCrtKeySpec 全参数还原)失败后回退 PKCS8EncodedKeySpec。

这意味着用户既能粘贴本工具生成的 PKCS#1 密钥,也能导入其他工具常见的 X.509/PKCS#8 格式。加解密使用 RSA/ECB/PKCS1Padding 并经 BC 提供者实例化;签名/验签使用 SHA256withRSA。除常规公私钥加解密外,引擎还提供 privateEncrypt/publicDecrypt 这一「私钥操作」组合,对应 acceptance.md 中 F14 条目描述的界面按钮(仅 RSA 启用,见 CryptoWiringPresentation.rsaPrivateReverseEnabled)。

deriveAsymmetricPublicKey 实现「从私钥还原公钥」:RSA 直接从 CRT 参数取 modulus 与 publicExponent 重新编码;SM2 则用标量 d 乘以曲线基点 G 得到未压缩点坐标后编码。该能力在 fixtures/electron-next-cryptoTools-vitest.md 中登记为 feature-parity 的「公钥还原」项,并配套独立测试 derivesPublicKeyFromPrivateForElectronSamplesAndRoundTrip。

SM2:C1C3C2 模式与 04 前缀的宽容解析

SM2 加解密使用 BouncyCastle 轻量级 SM2Engine(SM2Engine.Mode.C1C3C2),密钥基于 sm2p256v1 命名曲线:

private fun sm2Spec(): ECNamedCurveParameterSpec =
    ECNamedCurveTable.getParameterSpec("sm2p256v1") ?: GMNamedCurves.getByName("sm2p256v1").let { params ->
        ECNamedCurveParameterSpec("sm2p256v1", params.curve, params.g, params.n, params.h, params.seed)
    }

密文规范化逻辑 normalizeSm2Cipher 体现了与 Electron 样本的兼容细节:SM2 密文规范格式为 C1(65 字节,含 04 头) || C3 || C2,但 Electron 的 sm-crypto 输出中 C1 不含 04 前缀(64 字节裸坐标)。引擎两侧都做了处理:加密输出时剥掉引擎返回的 04 头字节;解密输入时若首字节不是 04 且长度 ≥ 96,则自动补上 04 前缀,从而「有/无 04 的 C1 都能消费」。验签失败统一返回 false 而非抛异常(getOrDefault(false)),与 UI 层验签按钮展示结果的语义匹配。

摘要与文件哈希:流式 64 KiB 分块

digestFile 对文件采用 64 KiB 缓冲的流式更新,避免一次性载入大文件:

fun digestFile(algorithm: DigestAlgorithm, path: Path): String {
    val size = Files.size(path)
    if (size > MAX_FILE_BYTES) {
        throw CryptoException("too-large", "File exceeds $MAX_FILE_BYTES bytes")
    }
    val digest = messageDigest(algorithm)
    Files.newInputStream(path).use { stream ->
        val buffer = ByteArray(64 * 1024)
        while (true) {
            val read = stream.read(buffer)
            if (read <= 0) break
            digest.update(buffer, 0, read)
        }
    }
    return toHex(digest.digest())
}

SM3 走 MessageDigest.getInstance("SM3", BouncyCastleProvider.PROVIDER_NAME),其余算法使用 JCA 默认提供者。验收记录「未测」一节如实说明:256 MiB 以上文件未测、哈希过程中没有单独取消按钮(只能通过文件对话框取消选择)——这些边界如实记录而非默认完成。

随机生成:长度边界与密码字符集强制覆盖

四类随机值共享 normalizeLength(1~4096 边界校验)与 SecureRandom:

  • UUID:UUID.randomUUID() 直接输出;
  • 数字:字符集 0-9;
  • 字符串:a-z A-Z 0-9 共 62 个字符;
  • 密码:四个字符类(小写、大写、数字、符号 `~!@#$%^&*()_+-=[]{};':,./<>?)中先各取 1 个(长度足够时)再随机补足,最后用 Fisher-Yates 洗牌打散位置。

对应测试 generatesConstrainedRandomValuesAndRejectsBadInput 用正则断言了 UUID 格式、^\d{32}$、^<a href="https://link.gitcode.com/i/1871ba59c00ad90e984dfe6f160701af" target="_blank">a-zA-Z0-9]{32}$,并验证 24 位密码四类字符齐全,同时覆盖非法输入:randomDigits(0) 越界抛错、非法 Hex zz 解密失败、非法 Base64 @@@ 解码失败。UI 层的操作按钮启用条件由 [RandomWiringPresentation / CryptoWiringPresentation.kt 统一守卫,例如 randomGenerateActionEnabled 只对非 UUID 类型校验长度落在 MIN_RANDOM_LENGTH..MAX_RANDOM_LENGTH 区间。

测试证据:五类用例与 Electron 基线对照

CryptoEngineTest(共 7 个测试方法,5 组核心断言组)是 72/72 结果中 CryptoEngine 部分的构成,与 Electron 版 vitest 的对照关系登记在 electron-next-cryptoTools-vitest.md:

  1. matchesElectronSymmetricAndDigestFixtures:固定向量字节兼容测试。"MooTool 加密" 以 1234567890abcdef 为密钥,AES/DES/SM4 三个方向各产生确定密文(如 AES 为 504a3eb1fee7af3af9561f37a6f12fa8),反向解密还原原文;摘要侧用 abc 的标准值锚定 MD5(900150983cd24fb0d6963f7d28e17f72)、SHA-256、SM3(66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0)。这是「与 Electron 样本字节一致」的直接证据。
  2. roundTripsBaseEncodingsAndFileDigest:Base64/Base32 往返("Moo 工具" → TW9vIOW3peWFtw== / JVXW6IHFW6S6LBNX),以及临时文件 abc 的 MD5 与文本摘要一致。
  3. consumesElectronRsaAndSm2Samples:直接消费 Electron 产生的 RSA-512 密文/签名与 SM2 C1C3C2 密文/签名——可解密还原 moo、可验签通过,证明跨产品密文可互换消费。
  4. generatesRsaAndSm2RoundTrips:自生成 RSA(测试中用 512 位加速)与 SM2 密钥对的加密/私钥操作/签名/验签全链路往返,且篡改内容后验签必须为 false。
  5. generatesConstrainedRandomValuesAndRejectsBadInput 与 derivesPublicKeyFromPrivateForElectronSamplesAndRoundTrip、symmetricKeyUtf8LengthMatchesNormalizationRules:随机约束与错误码、公钥还原与密钥状态(如 512 位 RSA 的 rsaModulusBits == 512)、以及上文详述的汉字密钥拒绝路径。

对照 Electron 基线 cryptoTools.test.ts(cryptoTools.test.ts)可以看到,Compose 侧不仅复刻了「round-trip AES/DES/SM4」「standard and national digests」「Base64/Base32 text」「constrained random values」「RSA/SM2 encrypt/sign」等同名场景,还把原本只在 UI 层验证的密钥字节规则(key-bytes-ui)与公钥还原(derive-public)提升为引擎级单测——这是 67/67 增长到 72/72 的来源。

UI 层接线:状态守卫与历史会话

引擎只负责纯计算,按钮启用状态抽到了 CryptoWiringPresentation 这类「纯函数守卫」对象中,例如:

fun canSymmetricCrypt(keyValid: Boolean, busy: Boolean = false): Boolean =
    keyValid && !busy

fun publicEncryptActionEnabled(status: AsymmetricKeyStatus, asymBusy: Boolean): Boolean =
    !asymBusy && status.publicReady

fun verifyActionEnabled(
    status: AsymmetricKeyStatus, plain: String, signature: String, asymBusy: Boolean,
): Boolean =
    !asymBusy && status.publicReady && plain.isNotBlank() && signature.isNotBlank()

非对称 Tab 的密钥可用性由 asymmetricKeyStatus 产出的 AsymmetricKeyStatus(公钥/私钥是否可解析、RSA 模数位数、SM2 公钥/私钥字节数)驱动,与验收记录中「非 ASCII 密钥拒绝并在界面说明」的语义相呼应。历史与分离窗口能力由 SessionManager.kt 中的 CryptoSession / CryptoSessionSnapshot 承载,SessionManager.cryptoSession(defaultRandomLength = 16) 提供了会话缓存入口;acceptance.md 中 F14 条目还补充了私钥操作输入与生成密码写入历史时做遮蔽等 UI 行为。

复现步骤与已知限制

复现验收

cd next-compose
export JAVA_HOME=$(/usr/libexec/java_home -v 21)   # macOS;其他平台指向 JDK 21
./gradlew :composeApp:desktopTest

scripts/run.sh 展示了官方的运行脚本约定:优先探测 JDK 21(/usr/libexec/java_home -v 21),并设置 MOOTOOL_COMPOSE_DATA_DIR 后执行 ./gradlew :composeApp:run 启动桌面应用。验收记录声明的本轮环境为 Zulu 21.0.12.1,即 JDK 21 是前提;BouncyCastle 1.80 的 bcprov-jdk18on 模块本身要求 Java 8+,但本产品的构建与测试基线以 JDK 21 为准。

已登记的边界与未测项

  • 与 Java Hutool 密文不互操作:密钥派生路径不同,属刻意不兼容(DIFF-008 理由:规格以 Electron 为兼容基线,且要求非 ASCII 密钥有明确的「成功/拒绝」而非口令派生);
  • 文件摘要 256 MiB 上限:超过即 too-large,该上限以上场景未测;哈希过程无独立取消按钮;
  • RSA 2048 位生成耗时:密钥生成在后台线程执行,DIFF-008 记录 2048 位生成「可能需数秒」;
  • 窗口截图、安装镜像:本轮未执行,属于验收记录中显式留待后续的证据项。

小结

F14 验收记录展示了一个典型的多端兼容加密工具实现路径:以 Electron 版本的密文与密钥行为为兼容基线,在 JVM 侧用 JCA + BouncyCastle 1.80 逐字节复刻(ECB+PKCS#7、小写 Hex、码点截断/补零后再按 UTF-8 字节校验、SM2 C1C3C2 且 C1 可含/不含 04 头、PKCS#1/X.509/PKCS#8 多格式 RSA 解析、SM3 走 BC 提供者),并通过固定向量测试(CryptoEngineTest)把「字节一致」固化为可回归的证据;同时把 Hutool 互操作不兼容、文件大小上限、无取消按钮等边界如实登记为差异或未测项。相关核心文件集中在 CryptoEngine.kt、CryptoEngineTest.kt、CryptoWiringPresentation.kt、008-crypto-key-bytes.md 与 results.md,可沿这些路径继续深入源码细节。

登录后查看全文
MooTool