
简介面向Android开发者的国密算法封装Demo在Java环境中实现SM2非对称加密、SM3密码散列与SM4对称加密适合需要在移动端集成国密算法、保障数据传输与存储安全的工程技术人员。资源共77个文件以Java源码、Gradle构建脚本、依赖JAR包为主另含XML配置、图片资源与Git版本目录压缩包约4.94MB。封装后的接口调用简洁覆盖SM2加密解密与签名验签、SM3摘要计算、SM4块加密解密等典型操作同时通过Bouncy Castle等库解决Android基础库不支持国密的问题。开发者可直接参考其目录结构将libs下的jar包与sm模块源码引入自身工程快速获得可运行的加解密能力。已有1536人学习对于初次在Android上落地国密算法的读者这是一份兼顾原理与实操的入门参考。 干国密算法封装这活儿说起来已经有快四年的经验了。从最开始一个简单的SM2加解密接口到后来把SM2、SM3、SM4三个算法统一封装成一套跨语言、跨平台的工具包中间踩过的坑绝对值得拿出来聊聊。如果你正在做国产化改造、等保合规、或者对接政企客户系统这篇文章应该能帮你省掉很大一部分试错成本。先说一个很现实的问题国密算法本身并不复杂复杂度在于你把它放在什么环境里用。Java环境还好说BouncyCastle和Hutool基本能覆盖但一旦涉及到前端JS、小程序、或者数据库加密场景算法库选型、密文格式对齐、编码转换这些细节全都会冒出来。封装不是简单包一层函数而是要站在业务角度把密钥管理、签名验签、加解密格式、跨语言联调全部统筹起来。1. 先搞懂三个算法的定位封装才不会乱1.1 SM2、SM3、SM4分别解决什么问题很多刚接触国密的人最容易犯的错是拿到需求后开口就问国密算法怎么写但国密是一个体系内部三个算法解决的是完全不同的问题。SM2是椭圆曲线公钥密码算法对标的是RSA和ECDSA。它主要做三件事签名验签、密钥交换、加解密。注意它虽然能做加密但性能远不如对称算法所以实践中SM2主要用来做数字签名和密钥交换加密场景优先考虑SM4。SM3是密码杂凑算法输出固定256位摘要对标SHA-256。它没有密钥概念不管输入多长输出长度固定主要用来校验数据完整性、生成摘要。SM3在国密体系里还有个特殊角色——它是SM2签名过程中的一个关键步骤先对数据算SM3摘要再对摘要做签名。SM4是分组对称密码算法分组长度128位密钥长度128位对标AES。它支持ECB、CBC、CTR、GCM等多种模式加解密速度很快适合加密大块数据。实际项目中SM4才是真正负责批量数据加密的角色。三者的定位就像是一个团队里的不同角色SM2负责确认你是谁、这话是你说的SM3负责检查数据有没有被改过SM4负责把真正想藏起来的内容锁进保险柜。1.2 三个算法如何搭配使用典型业务链路理解了各自定位再看实际业务链路就非常清晰了。我做过的一个典型应用是文件安全交换系统整体流程是这样的客户端用SM4随机密钥加密文件内容然后对这个SM4密钥用服务端的SM2公钥进行加密同时用SM3对文件内容生成摘要再用本地SM2私钥对摘要进行签名。服务端收到后先用自己SM2私钥解出SM4密钥用SM4密钥解出文件再用对方公钥验签、用SM3校验完整性。这套流程把三个算法各归其位的用法体现得非常彻底SM4做内容加密SM2做密钥保护的身份认证和防抵赖SM3做完整性校验。封装的时候如果你只是把三个算法分别封装成三个工具类那和直接用开源库没有区别真正有价值的封装是把这个链路本身也沉淀成可复用的方法比如提供加密并签名解密并验签的组合接口。2. 封装设计从业务视角倒推API2.1 为什么要做统一封装如果说用一个算法是调用API那么把三个算法封装起来就是为了让调用方少想事。坦率讲国密算法库的原生API对业务开发并不友好尤其是跨语言场景两边参数对不齐是家常便饭。我做封装前踩过这样一个坑Java端SM2加密输出的密文默认格式是C1C3C2但前端JS端某个老版本库用的是C1C2C3。两边单独跑都是对的联调就是死活验不过。后来一排查就是这个格式错位。这种问题如果不在封装层统一解决每个业务方都会遇到而且很难排查。统一封装有几个明确好处第一把密钥格式、密文格式、编码方式这些容易出错的细节收敛在一层第二对外提供面向业务的语义化方法比如数字信封加密签名并加密Hash并签名而不是让业务方自己拼装流程第三可以统一日志、统一异常处理、统一性能统计。说白了封装的价值不是让你少写几行代码而是让你不在底层细节上反复返工。2.2 封装层的核心设计原则我在设计封装层时有几个原则是必须守住的你可以直接拿去参考。第一双语言API对齐。Java和JS封装的对外方法名、参数顺序、返回值结构保持完全一致。这样联调时两边对照看不会蒙圈。比如Java端定义String encryptByPublicKey(String plainText, String publicKey)JS端一定保持同名同参数。第二默认值统一收敛。所有算法参数、填充模式、编码方式封装层内部确定不暴露给业务方。比如SM4统一用CBC模式加PKCS7PaddingSM2密文统一用C1C3C2格式密钥统一十六进制字符串摘要统一转大写。业务方不需要理解这些选项只需要传明文和密钥。第三密钥格式标准化。公钥、私钥统一为十六进制字符串base64只作为传输格式在边界处转换内部运算一律用HEX。这个约定在跨语言时特别重要JS和Java对base64的兼容性虽然好但经常会有前缀、换行符、URLSafe编码的差异统一用HEX能少掉很多坑。第四组合方法下沉。把加密并签名验签并解密这类组合流程直接封装成高层方法。业务方不要自己先调SM4再调SM2而是调一个secureSend方法组合逻辑由封装层保证。这样也便于后续做安全升级比如替换密钥长度、增加随机因子业务方无感知。3. 双语言落地Java和JS的代码实现要点3.1 Java端实现BouncyCastle 自研封装Java端目前最成熟的做法是使用BouncyCastle库Hutool也封装了SM2、SM3、SM4但Hutool在密文格式、模式切换上灵活性一般。我倾向于基于BouncyCastle做底层实现再用自研类做业务封装。先看一个SM2加密的核心实现思路// 引入BouncyCastle后SM2算法提供者会自动注册 public String encryptByPublicKey(String data, String publicKeyHex) { // 1. 将十六进制公钥还原为BC库的SM2PublicKey对象 X509EncodedKeySpec keySpec new X509EncodedKeySpec( new SM2PublicKey(publicKeyHex).getEncoded()); PublicKey publicKey KeyFactory.getInstance(EC, BC) .generatePublic(keySpec); // 2. 使用SM2Engine设置C1C3C2模式 SM2Engine engine new SM2Engine(SM2Engine.Mode.C1C3C2); // 3. 使用随机数做加密每次结果不同是正常的 SecureRandom random new SecureRandom(); byte[] cipherBytes engine.init(true, new ParametersWithRandom( new ECPublicKeyParameters(...), random)); byte[] plainBytes data.getBytes(StandardCharsets.UTF_8); byte[] encrypted engine.processBlock(plainBytes, 0, plainBytes.length); // 4. 转HEX输出 return Hex.toHexString(encrypted); }这里有几个关键点必须注意SM2加密默认使用随机数所以同一份明文加密两次的结果完全不同这是正常现象不是bug。另外BC库的SM2Engine有C1C2C3和C1C3C2两种模式2024年之后所有版本基本推荐用C1C3C2这也是国标推荐格式。SM3摘要相对简单直接调用库函数即可public String hash(String data) { SM3Digest digest new SM3Digest(); byte[] input data.getBytes(StandardCharsets.UTF_8); digest.update(input, 0, input.length); byte[] result new byte[digest.getDigestSize()]; digest.doFinal(result, 0); return Hex.toHexString(result).toUpperCase(); }SM4加解密实现同样用BC库的SM4Engine配合CBC模式时需要对IV做处理这里有一个很细的坑IV必须固定16字节如果业务方传的IV长度不对BC库可能会报illegal argument或者静默截断这个在封装层要做严格校验。3.2 JS端实现sm-crypto类库JS端我目前使用比较顺手的是sm-crypto这个库轻量、无依赖支持SM2、SM3、SM4API设计也比较符合前端习惯。关键用法如下const sm2 require(sm-crypto).sm2; const sm3 require(sm-crypto).sm3; const sm4 require(sm-crypto).sm4; // SM2加密密文格式C1C3C2 function encryptByPublicKey(data, publicKeyHex) { const cipherMode 1; // 1表示C1C3C20表示C1C2C3 return sm2.doEncrypt(data, publicKeyHex, cipherMode); } // SM2签名 function sign(data, privateKeyHex) { return sm2.doSignature(data, privateKeyHex, { hash: true }); } // SM3摘要 function hash(data) { return sm3(data).toUpperCase(); } // SM4-CBC加密 function sm4Encrypt(data, keyHex, ivHex) { return sm4.encrypt(data, keyHex, { mode: cbc, iv: ivHex, padding: pkcs7 }); }JS端最需要注意的是字符串编码问题。sm-crypto内部处理UTF-8字符串时有时会绕不过去特殊字符尤其是中文和多字节Emoji。我在实际项目中踩过一个坑同样是明文中国123abcJava端SM3摘要和JS端sm3结果一致但在SM4加密中文时两边结果对不上。后来排查发现是JS端需要先把字符串转成UTF-8字节数组再交给库处理或者统一用TextEncoder预处理。3.3 联调时最容易翻车的几个细节跨语言联调时我总结出几个高频翻车点这些细节如果你遇到过绝对会深有同感。第一个是密文格式不一致。刚才说过的C1C2C3和C1C3C2问题这个在Java和JS的默认参数上非常容易错位。Java端BC库老版本默认C1C3C2没问题但JS端sm-crypto的默认值是C1C2C3你必须显式传cipherMode: 1。不统一这个其他全对也没用。第二个是摘要大小写。SM3摘要谁转成大写、谁保持小写这个如果不统一前端筛字符串比对时会莫名其妙失败。我的做法是统一大写并且在封装层做规整。第三个是密钥过长报错。SM2公钥前端传过来有时是带有04前缀的有的库默认不带导致解密时格式不识别。我的建议是封装层统一处理如果密钥长度是130位自动截取或用工具类去掉04前缀再进入核心逻辑。第四个是IV和Key字节数问题。SM4的Key必须是16字节如果业务方传过来的HEX字符串是32位十六进制正好对应16字节但如果传的是base64字符串就需要先解码封装层必须提前做好校验。4. 高频的业务疑问加密还是签名验签怎么验浏览器端适配4.1 PDF文件该用SM2加密还是签名这个问题我在做电子公文系统时被反复问过。先说结论大多数PDF场景下核心需求是签名不是加密。PDF本身是明文可读的文件业务上更多需要的是确认这份PDF是谁签发的、内容有没有被篡改这个需求对应的是SM2签名而不是SM2加密。SM2签名流程是计算PDF文件的SM3摘要 → 用签名方私钥对摘要做SM2签名 → 将签名值附在PDF元数据或独立的签名文件中。接收方用公钥验签验证摘要一致性和签名有效性就能判断文件是否被篡改、是否由指定方签发。如果是机密性质的PDF需要保证文件内容本身不可读那才需要先对PDF做SM4加密再用SM2加密SM4密钥也就是数字信封方案。我见过不少把加密和签名混用的设计结果系统既慢又臃肿。记住一个判断标准要防篡改和确认来源就用签名要防泄露就用加密两者是互补关系可以组合用但不能互相替代。4.2 SM2验签流程拆解SM2验签这个动作在封装层实现时有不少细节。直接看流程图会清晰很多我这里用文字逐步拆解拿到签名端消息、签名值、公钥后第一步是计算消息的SM3摘要得到256位哈希。第二步是把签名值用固定的DER编码方式解析成r和s两个大整数。第三步是使用SM2公钥结合摘要和r、s做椭圆曲线点运算最终判断是否通过。这个过程中最容易出错的是签名值的编码格式。SM2签名有两种表示方式原始r||s拼接是被很多库直接支持但业务方往往传的是DER格式。Java端BC库默认产生DER编码签名而JS端sm-crypto产生的是r||s拼接格式。我在封装层做了一个自适应处理先判断签名值长度是固定64字节就是r||s格式长度更长就当DER处理。另外还要注意签名验签时消息本身的内容。有些场景下签名的是原始文件内容有些场景是签名原始文件的base64编码这两种情况摘要完全不一样。所以封装层必须明确约定签名前的消息处理规则我一般统一为对原始字节数据做SM3摘要后签名。4.3 Firefox等浏览器的国密证书适配随着国密SSL证书的推广前端HTTPS连接也开始面临国密算法适配的问题。Firefox浏览器对国密证书的支持比较特殊主流版本默认不支持国密SSL证书需要通过国密浏览器或特定配置方式扩展支持。我这里单独说一下浏览器端的国密HTTPS和算法封装之间的关系。如果你的系统需要同时支持国际算法和国密算法的HTTPS连接通常的做法是在服务端或网关层做双证书配置即同时配置国际RSA证书和国密SM2证书根据浏览器握手能力自动协商选用哪套证书。对于纯前端JS来说更实际的适配是当页面通过国密HTTPS通道加载后页面内的加解密逻辑仍然走我们上面说的SM2/SM3/SM4封装。所以封装层本身不需要关心底层是不是国密证书但你的业务API可能需要针对国密通道做一些调整比如从证书中动态读取SM2公钥再用于业务签名验签而不是硬编码在代码里。5. 实战避坑清单与排查手段5.1 高频报错与排查方向我把实际项目里遇到过的报错和排查方法整理成了一张速查表你可以收藏起来遇到问题直接对照报错现象常见原因排查方向BC库报invalid CipherTextSM2密文被截断或格式模式不匹配检查密文长度是否128位HEX以上检查C1C3C2和C1C2C3模式JS端SM2解密返回空字符串公钥格式带不带04前缀不匹配统一密钥处理逻辑加解密用同一规则SM4解密出现乱码IV长度不对、填充模式不一致确认V为16字节两端统一使用PKCS7Padding签名验签失败但摘要一致签名值编码格式不同DER vs raw在封装层增加兼容逻辑统一编码格式SM3摘要对不上字符串编码不一致统一使用UTF-8避免使用平台默认编码其中的核心经验是遇到跨语言问题优先排查编码格式其次排查密文排列格式最后才考虑算法本身是否正确。从一开始看到这类问题就不要怀疑算法库绝大多数是自己封装层没对齐。5.2 安全加固密钥管理与防逆向最后说一个很多人忽略的点国密算法封装本身是安全能力的一部分但封装层如果做得不好反而会成为新的攻击面。特别是JS端国密算法逆向的风险尤其需要警惕。在JS端你永远不要信任前端代码里的密钥无论是公钥还是私钥。SM2公钥在前端是公开的本身不敏感但私钥绝不能出现在前端代码中。如果业务要求前端做SM2签名可以考虑使用部分私钥后移或中心签名代理方案也就是前端只负责收集数据签名请求发给服务端执行或者只留公钥验签能力。如果是做H5或者小程序被要求做国密算法逆向分析核心其实是通过混淆工具对JS代码做压缩和变量名混淆隐藏核心密钥和算法调用逻辑。我建议的实践组合是UglifyJS或JavaScript混淆器压缩混淆 WebAssembly封装核心算法 运行时动态生成密钥内存使用。但必须说清楚混淆和WASM只能增加逆向成本做不到绝对安全。真正安全的密钥必须放在服务端。Java端更容易被忽略的是内存安全。SM4密钥在内存中是byte数组GC后内存未立即清除高强度场景建议用完立即对byte数组填充0。日志中禁止打印密钥和密文内容这是我在代码Review时反复强调的点。6. 最后想说的一点点经验做算法封装这几年最大的体会是算法只是工具箱封装才是落地能力。你不需要把SM2的数学原理背得滚瓜烂熟但你必须知道C1C3C2和C1C2C3的区别知道JS和Java的摘要大小写习惯不一致知道跨语言联调要统一编码和格式。这些细节才是一个封装工具包能不能真正被团队用起来的决定因素。如果你正准备做国密改造或者重新封装现有算法库我建议你先别急着写代码花上半天时间把所有跨语言调用场景梳理一遍把所有格式参数列个清单确定好统一的约定。这个准备时间会节省你之后几天甚至几周的联调排错时间。最后再分享一个小技巧给封装层加一套自测程序分别在Java端和JS端用同一组密钥和明文跑一遍加解密、签名验签、摘要计算直接比对输出结果。我自己的工具包就是这么做的每次改完底层库版本或者参数调整先跑一遍双端自测两分钟就能发现所有兼容性问题。这套自测代码就是整个封装体系里最值钱的部分之一。本文还有配套的精品资源点击获取