news 2026/10/21 8:10:53

Android国密SM2集成实战:GmSSL编译与JNI封装指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android国密SM2集成实战:GmSSL编译与JNI封装指南

国密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.a

CMakeLists.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摘要
签名长度不是64DER编码的ASN.1签名结构转成r
加载so时报UnsatisfiedLinkErrorso文件未按ABI目录放,或abiFilters未配置检查jniLibs目录和abiFilters
真机卡顿在UI线程执行加解密放到子线程/协程
编译报undefined referenceGmSSL静态库和头文件版本不一致重新编译,统一源码版本

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能帮你在第一个坑前刹住车。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 8:10:41

小说漫画漫剧副业低成本指南:预算分配与省钱实战

说实话,身边十个想搞副业的人,七个第一个想到的就是写小说、画漫画或者做漫剧。但我见过太多开局方式一言难尽:为了写小说先花几千块报班,为了画漫画直接上全套高端设备,还有人想做漫剧,素材、外包、音乐授…

作者头像 李华
网站建设 2026/10/21 8:10:31

开放模型如何降低AI推理成本:从选型到本地部署的完整指南

开放模型正在把 AI 的使用成本拉下来,这件事已经不是趋势判断,而是过去一年最清晰的产业曲线。同一个档次的模型能力,从按量付费、按 token 计费的闭源 API,到可以免费下载权重、自己部署的开放模型,成本结构发生了根本…

作者头像 李华
网站建设 2026/10/21 8:10:09

三菱PLC入门:从指令到梯形图的仿真实践与避坑指南

简介:《三菱PLC基础学习教程》是一份面向电气自动化初学者与PLC入门人员的PDF学习资料,系统讲解三菱PLC的硬件结构、输出接口电路、电源管理与循环扫描工作原理。内容从接口隔离方式、响应时间、输出电流、开路漏电流到公共端配置均有详细说明&#xff0…

作者头像 李华
网站建设 2026/10/21 8:09:47

从零掌握iptables:Linux防火墙的核心原理与实战规则

1. 从零开始理解iptables:它到底解决了什么问题搞 Linux 运维或者开发的人,迟早都会碰到 iptables。我第一次真正正视它,是因为一台测试服务器莫名其妙连不上外网,排查到最后发现是别人在机器上顺手敲了一条默认拒绝 INPUT 链的命…

作者头像 李华
网站建设 2026/10/21 8:07:56

C# WinForms推箱子实战:零依赖可运行游戏框架

简介:这是一份基于C#开发的推箱子小游戏完整源码项目,面向C#初学者与Windows桌面应用编程学习者,帮助理解事件驱动、图形绘制、文件I/O及简单游戏逻辑设计等核心知识点。资源包含76个文件,涵盖10个关键C#源文件(如Form…

作者头像 李华
网站建设 2026/10/21 8:07:48

JMeter绿色版部署与中文设置实战指南,附压测流程与排坑经验

JMeter 这个工具,做性能测试的人基本都绕不开。它开源、免费、插件生态丰富,能压接口、压数据库、压消息队列,几乎什么都能测。但真正上手的时候,不少人会卡在第一步:装环境、配 JDK、启动报错,然后界面还是…

作者头像 李华