国密SM2在Android端落地这件事,说难不算难,说简单也真不简单。要把GmSSL编译成能在手机上跑的so库,再通过JNI把SM2的密钥生成、加解密、签名验签全部调通,中间要趟的坑,比官方文档里写的要多不少。这篇文章我直接用一套已经跑通的Demo来讲:怎么给Android工程挂上GmSSL、怎么封装一套可复用的SM2 JNI接口、以及我在跟服务端联调时踩过的格式和编码问题,希望能给准备做国产密码接入的朋友省几个晚上。
1. 项目背景与技术选型思路
1.1 为什么Android端要用SM2国密算法
现在不少业务系统都在做密码改造,服务端网关换了SM2之后,App端的数据加密和签名就得跟着换。Android本身没有直接支持SM2的内置类,Java层也没有现成的标准实现,想用就得自己引第三方密码库。要么用纯Java的BouncyCastle,要么用C层引GmSSL这类开源库,两条路各有各的走法。
纯Java方案的好处是集成简单,一个jar或者依赖就完事,不碰NDK,不需要照顾so库的ABI和编译工具链。坏处是性能不是最理想的,而且很多服务端C库的经验、命令、格式文档不完全套得上,尤其是看GmSSL/OpenSSL风格文档的时候,总差一层。C方案正好反过来,性能好、和服务端的C库接口风格统一、底层行为容易对得上,但前提是你愿意折腾NDK编译和JNI这一套。
我当时选择GmSSL的原因很直接:服务端用的就是同一套库,密钥和密文格式对齐是最省事的。而且GmSSL不仅仅是SM2,SM3、SM4、SM9、ZUC都带,后面功能扩展不用再换库。当然,如果只是想在Java层快速验证算法流程、又不希望引入NDK,用BouncyCastle也完全合理,这个问题没有标准答案,只看项目现状。
1.2 GmSSL这个库能解决什么问题
GmSSL是一个开源密码库,API风格对用过OpenSSL的人特别友好,很多函数名、头文件、上下文管理方式跟OpenSSL高度相似。它包含了国密全套算法,SM2椭圆曲线公钥密码算法、SM3密码杂凑算法、SM4对称算法都有,覆盖了密钥交换、加密、签名验签、消息摘要这些常用场景。
对Android开发来说,GmSSL的价值在于它能被交叉编译成ARM平台的库,然后在JNI层被调用。因为底层是C实现,不依赖Java的加密Provider体系,所以不存在Android版本割裂导致的算法不可用问题,行为跟服务端C库是完全一致的。这对联调来说太重要了,很多坑其实不是算法跑不通,而是两端的实现库对同一个格式的理解不同,用同一个库从根源上减少了一半争论。
1.3 方案选型对比
| 方案 | 算法覆盖 | 集成方式 | 性能 | 联调友好度 |
|---|---|---|---|---|
| BouncyCastle | 支持SM2/SM3/SM4,持续维护 | 纯Java依赖,无需NDK | 中等 | 格式定义灵活,但需自行对齐文档 |
| GmSSL | 支持SM2/SM3/SM4/SM9/ZUC | 源码交叉编译,JNI调用 | 较好 | 与C服务端同源,行为一致性好 |
| 某些商业/半成品封装 | 以SM2居多 | 一般也是依赖库方式 | 不等 | 需确认底层实现和更新节奏 |
我的实际建议是:如果你是一个人写Demo、想快速看清楚国密长什么样,用纯Java方案先验证思路是没问题的。如果是要交付到生产环境,或者服务端已经是C库实现,我倾向于用GmSSL,因为后面加密格式、签名编码这些细节跟服务端对齐时,少很多“你这个库怎么这么怪”的来回沟通。
2. 工程搭建与GmSSL编译集成
2.1 NDK环境准备与交叉编译
编译GmSSL到Android平台,核心是拿到一份能在目标ABI上运行的库文件。我用的是NDK + CMake的方式,工具链由NDK自带的Android.toolchain.cmake提供。
需要准备的东西不多:Android Studio里配好的NDK、CMake,以及一份GmSSL源码。NDK版本我用的r21以上的,太旧的版本在编译新源码时容易报一些奇怪的链接错误。交叉编译的大致命令如下:
# 假设ANDROID_NDK是你的NDK路径 export ANDROID_NDK=$HOME/Android/Sdk/ndk/21.4.7075529 cmake -S . -B build_arm64 \ -DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK/build/cmake/android.toolchain.cmake \ -DANDROID_ABI=arm64-v8a \ -DANDROID_PLATFORM=android-21 \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_INSTALL_PREFIX=$PWD/out_arm64 cmake --build build_arm64 cmake --install build_arm64把ABI换成armeabi-v7a或x86_64就能出对应平台的库。如果拿到的是比较旧的GmSSL版本,源码目录里没有CMakeLists,那就退回到config/make那套路线,用NDK的clang指定CC和AR去交叉编译。旧版本的编译脚本兼容性要差一些,建议能上新版就上新版,新版代码结构更干净,构建方式也现代。
2.2 Android项目结构设计与CMake配置
编译好的静态库可以直接打进App,不需要单独发一个so。我的项目结构大概长这样:
app/ src/main/ cpp/ CMakeLists.txt sm2_jni.c java/com/example/sm2demo/ Sm2Utils.java libs/ arm64-v8a/ libgmssl.aCMakeLists.txt里最关键的是把GmSSL的头文件路径和静态库路径传进去:
cmake_minimum_required(VERSION 3.10) project(sm2demo) set(GmSSL_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/../libs) include_directories(${GmSSL_ROOT}/include) add_library(sm2_jni SHARED sm2_jni.c ) target_link_libraries(sm2_jni ${GmSSL_ROOT}/${ANDROID_ABI}/libgmssl.a log )然后在模块的build.gradle里指定CMake脚本路径:
android { defaultConfig { externalNativeBuild { cmake { cppFlags "" } } } externalNativeBuild { cmake { path "src/main/cpp/CMakeLists.txt" } } }这里有个具体细节:静态库的路径里用了${ANDROID_ABI},这是CMake在Android构建时自动传入的变量,会对应armeabi-v7a、arm64-v8a这种文件夹名字,所以在libs目录下要按ABI分文件夹摆放库文件。
2.3 动态库加载与初始化检查
编译产物是libsm2_jni.so,Java层加载方式很简单:
static { System.loadLibrary("sm2_jni"); }因为libgmssl.a是静态编译进去的,不需要额外load一个libgmssl.so。我曾经见过有人在加载顺序上纠结半天,后来发现静态库根本没有so可以load,纯属多虑。
跑Demo之前可以先做一个最简单的native方法试试加载是否成功,比如返回一个版本号字符串。如果这一步挂了,先别急着写算法逻辑,把编译产物和ABI目录结构问题排查清楚再说。整个链路里,加载失败多数是库放错目录、CMake链接路径写错、或者ndk abiFilters没配全导致的。
3. SM2核心流程与JNI封装实现
3.1 密钥对生成与数据格式约定
SM2基于的是sm2p256v1椭圆曲线,私钥是一个256位整数,通常用32字节表示;公钥是椭圆曲线上的一个点,常见表示有两种:64字节的裸x||y,以及65字节的04||x||y。04是未压缩点的前缀标记,很多服务端接口喜欢直接用64字节,也有的习惯带04,这个必须提前约定。
密钥生成的核心调用很短:
EC_KEY *ec_key = EC_KEY_new_by_curve_name(NID_sm2); EC_KEY_generate_key(ec_key);生成之后从EC_KEY对象里分别导出私钥和公钥。导出私钥时要注意固定补齐到32字节,不要用BN_bn2hex这种变长格式;导出公钥用i2o_ECPublicKey,得到的就是65字节的未压缩点。
我在JNI里设计的生成接口是两步分开的:
public static native byte[] sm2GeneratePrivateKey(); public static native byte[] sm2DerivePublicKey(byte[] privateKey);这样Java层可以分别拿到私钥和公钥,私钥32字节,公钥65字节。为什么要分两步?因为实际业务里经常是私钥存在本机,公钥每次动态推导或者从服务端拉取,拆开用起来更灵活。
3.2 加密与解密流程详解
SM2加密的过程可以这样理解:加密方生成一个随机数k,把k和椭圆曲线基点做标量乘法得到点C1;再用k和对方的公钥算出一个共享点,通过密钥派生函数KDF生成密钥流;明文跟密钥流异或得到C2;最后用SM3对关键信息做杂凑得到C3。最终密文由C1、C3、C2三部分组成。
这里就是第一个大坑:这三部分的拼接顺序。GmSSL默认输出的是C1||C3||C2,但有些历史实现和早期标准文档写的是C1||C2||C3。如果两端用的库不一致,加密出来对方解密就是乱码,而这个乱码现象不会报错,特别容易让人误以为是密钥配错了。
JNI层的加密核心片段大致是这样的:
unsigned char *cipher = OPENSSL_malloc(msg_len + 128); size_t cipher_len = 0; int ok = SM2_encrypt(EVP_sm3(), (unsigned char *)msg, msg_len, ec_key, cipher, &cipher_len);不同版本的GmSSL函数签名会有细节差异,有的第一个参数是const EVP_MD*,有的是EVP_MD*,联调前先看一眼头文件比什么都靠谱。返回的cipher就是拼接后的密文,长度大约是65 + 32 + msg_len。
解密调用方向相反,底层会提取C1恢复成一个点,用私钥计算共享密钥,再用KDF还原密钥流,最后校验C3。解密时如果密文被截断、顺序错乱、或者公钥没配对,通常会返回长度异常、SM3校验失败这类结果。
3.3 签名与验签流程详解
SM2签名不是直接对消息算个Hash就签,它要先构造一个Z值。这个ZA由用户ID、椭圆曲线参数和公钥共同推导出来,签名时实际处理的是Z与消息的拼接。默认用户ID固定是"1234567812345678"十六个字节。
很多第一次接SM2签名的人在这一步翻车:本地签名验签怎么都对,跟服务端联调就验签失败,最后发现是用户ID不一致。GmSSL默认ID是"1234567812345678",但服务端如果用的其他密码库,可能默认ID不一样,甚至自己指定了业务ID。这个参数必须在联调一开始就确认。
签名调用用EVP接口比较省事:
EVP_MD_CTX *md_ctx = EVP_MD_CTX_new(); EVP_PKEY *pkey = EVP_PKEY_new(); EVP_PKEY_assign_EC_KEY(pkey, ec_key); EVP_DigestSignInit(md_ctx, NULL, EVP_sm3(), NULL, pkey); EVP_DigestSign(md_ctx, sig, &sig_len, data, data_len);到底传原始消息还是传SM3摘要,取决于你的GmSSL版本和调用方式。如果EVP_DigestSign返回的长度是64,那返回的是裸的r||s拼接;如果返回长度在70到72之间,那多半是ASN.1 DER编码的签名结构。这两种格式在对接时必须分清,Java层拿到之后要看第一个字节,是0x30开头就说明是DER结构。
验签完全对称:
EVP_DigestVerifyInit(md_ctx, NULL, EVP_sm3(), NULL, pkey); int ok = EVP_DigestVerify(md_ctx, sig, sig_len, data, data_len);3.4 Java层接口设计与数据格式约定
我把Java层的封装做得尽量薄,只做byte[]传递,不在Java层做任何加解密计算,所有算法逻辑都沉淀在native层。接口设计如下:
public final class Sm2Utils { static { System.loadLibrary("sm2_jni"); } public static native byte[] sm2GeneratePrivateKey(); public static native byte[] sm2DerivePublicKey(byte[] privateKey); public static native byte[] sm2Encrypt(byte[] publicKey, byte[] data); public static native byte[] sm2Decrypt(byte[] privateKey, byte[] ciphertext); public static native byte[] sm2Sign(byte[] privateKey, byte[] data); public static native boolean sm2Verify(byte[] publicKey, byte[] data, byte[] signature); }为什么接口层全部用byte[]而不是String?因为算法处理的是原始字节,用String传递再搞编码转换,中间很容易引入多一步的坑。日志里要看结果就用Hex/Base64工具转换,业务层需要传输再编码成字符串,算法层不要碰编码问题。
Java层我会配合一组工具方法做格式化,比如byte数组转Hex字符串、Hex字符串转回byte数组。这些工具不影响算法逻辑,但对排查问题特别重要,因为日志里全是不可打印字节时,你根本不知道是哪个环节错了。
4. Demo运行实测与联调要点
4.1 Demo的界面与完整流程
这个Demo我做得比较简单,一个Activity,几个按钮分别触发生成密钥、加密、解密、签名、验签,下方一个日志输出区域展示结果。
点击生成密钥,native层生成32字节私钥和65字节公钥,日志区域用Hex打出来。点击加密,输入一段明文,调用sm2Encrypt,输出密文Hex。点击解密,把上一步的密文传进去,还原成明文。签名验签的顺序也类似,签完立刻拿公钥验一次,返回true表示本地闭环通过。
界面逻辑没什么技术含量,重点是跑通之后手里有一批真实的测试数据,后面跟服务端联调可以直接拿来对比。
4.2 实测数据形态参考
本地跑出来的数据大概长这样,随机数不同每次值都会变,但结构和长度是稳定的:
私钥(32字节hex): 5b7e9d3c8f6a1c0d9e7b2a4f6c8e1d0a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e 公钥(65字节hex): 04e5a4b7c2d891e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0 密文(104字节hex): 04...(65字节C1) + ...(32字节C3) + 7字节C2明文长度是多少,C2长度就是多少。所以SM2加密虽然安全性没问题,但密文会比明文长97字节左右,这个特性在做传输通道设计时要提前考虑。签名结果如果是裸r||s,固定64字节;如果是DER编码,长度70到72字节浮动。
4.3 与服务端联调的核对清单
本地闭环通过只是第一步,真正耗时间的是跟服务端把格式对齐。我整理了一份核对清单,每次联调前先过一遍,能省下大量来回试错的时间:
| 核对项 | 常见不一致点 | 建议约定 |
|---|---|---|
| 公钥格式 | 64字节裸点 vs 65字节带04前缀 vs DER/PEM编码 | 明确原始字节还是带前缀 |
| 密文顺序 | C1C3C2 vs C1C2C3 | 服务端文档常写但容易忽略 |
| 用户ID | 默认1234567812345678还是业务自定义值 | 双方必须一致 |
| 签名格式 | 64字节r | |
| 编码方式 | Hex还是Base64,大小写 | 传输层提前约定 |
这五项只要有一项不一致,现象就是解密乱码或者验签失败,而且错误信息不会有明确的“格式不对”提示,只能靠排查。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
我在做这个Demo和后续联调过程中,遇到过不少问题,真正高频的集中在下面几个:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 解密后是乱码 | 密文拼接顺序C1C2C3/C1C3C2不一致 | 用固定测试数据对比hex,找出顺序差异 |
| 验签始终失败 | 用户ID不一致 | 与对端确认SM2签名用户ID,统一值 |
| 验签失败且时长异常 | 签名数据传错,原文/摘要语义不一致 | 确认签名时传的是原文还是先算SM3摘要 |
| 签名长度不是64 | DER编码的ASN.1签名结构 | 转成r |
| 加载so时报UnsatisfiedLinkError | so文件未按ABI目录放,或abiFilters未配置 | 检查jniLibs目录和abiFilters |
| 真机卡顿 | 在UI线程执行加解密 | 放到子线程/协程 |
| 编译报undefined reference | GmSSL静态库和头文件版本不一致 | 重新编译,统一源码版本 |
5.2 日志排查三板斧
排查这类加密问题,我最常用的三个手段是:hexdump、固定向量比对、分组二分。
拿到一段密文先不要急着看内容,转成hex之后切成几段,根据预期的C1长度和C3长度判断顺序对不对。如果第一段长度是65且以04开头,那C1就在前面;如果第一段看起来像乱码但长度等于明文长度,很可能C2被排到最前面了。
固定向量比对是指用同一份明文、同一把密钥,在两端分别跑一次,然后把hex输出放在一起逐字节对比。这个方法能快速定位是密钥不同还是格式不同。
分组二分适用于复杂的联调失败场景:先只测一端加密另一端解密,定位是加密侧问题还是解密侧问题;再把密钥、密文、附加参数分别替换成对方提供的值,逐步缩小范围。
5.3 两个容易忽略的边界问题
很多问题不是算法本身,而是边界条件。比如私钥不能是0或大于曲线阶n,否则生成公钥会异常。再比如公钥的x、y坐标如果前导有0字节,转成hex时位数会不够,如果按固定长度解析就会出现长度错位。
我遇到过一种情况:公钥Hex字符串长度比预期少了2位,一看是x坐标首位0被某个工具去掉了。解决办法是在代码里强制按32字节补齐,绝不在字节转hex环节做任何裁剪。这类问题在老库和接口不规范的系统中特别常见。
6. 安全加固与后续扩展
6.1 私钥保护与安全注意事项
Demo里把私钥直接放在内存和日志里是没问题的,因为这只是演示。真正上线时私钥的安全存储是重中之重。
比较合理的Android做法是:首次启动生成SM2密钥对后,用Android Keystore里的RSA或EC密钥对SM2私钥做一层包装加密,再把密文存到本地文件或数据库。每次使用前从Keystore取出包装密钥解密SM2私钥,用完立刻清掉内存引用。虽然这不能做到绝对安全,但比把私钥明文存在SharedPreferences里要强太多。
还有一个细节:日志里不要打印私钥。我见过不少开发同学调试加密问题习惯性把密钥直接打出来,上线忘了删,这是事故源。可以打公钥打签名打密文,就是不要打私钥。
6.2 进一步扩展的方向
这个Demo跑通之后,扩展路径很清晰。需要对称加密的场景,可以用同样的JNI模式封装SM4;完整性校验直接用SM3,国产体系里基本是标准配置;SM2密钥交换协议也有API,可以做双向安全通道。
我建议下一步做一个完整的加密工具类:用SM4加密业务数据,用SM2加密SM4的对称密钥,签名用SM2,摘要用SM3。这套组合在国产密码应用里很典型,做完之后你会发现整个密码改造的骨架就立住了。
最后说一个我自己的切身体会。国密算法本身不是障碍,真正的障碍往往是“约定”二字。我第一次跑通本地加解密非常顺利,结果去对接服务端时,一下午就耗在密文顺序和用户ID上。后来我养成一个习惯:动手前先跟服务端把密钥格式、密文顺序、签名格式、用户ID这四项写成一句约定,甚至拉一个最小样例互跑。这个动作看起来很小,但对联调效率的提升特别明显。如果你也正在做Android端国密接入,希望这篇Demo能帮你在第一个坑前刹住车。