
简介面向Android开发者的国密算法封装Demo基于Java实现SM2非对称加密、SM3密码散列与SM4对称加密覆盖公钥加解密、数字签名、数据完整性校验和块加密等典型场景。项目中已封装可直接调用的函数集成时仅需少量代码即可完成国密加解密操作适合需要满足国内合规要求或提升数据传输安全性的App开发者。资源包共77个文件以xml配置、java源码、gradle构建脚本、jar库文件及png图片为主整体大小4.94MB目录包含app与sm两个模块结构清晰便于直接导入Android工程使用。目前已有1536人学习下载。包内含Android端可运行的示例工程包括Gradle构建配置、SM算法源码、依赖jar包及ProGuard规则可帮助开发者快速理解国密算法在Android平台的接入方式并作为项目基础进行二次开发。 这么多年做安全相关的项目国密算法这个坑算是踩得比较多的一个。最近正好把SM2、SM3、SM4三种算法从Java到JS端完整封装了一遍中间还牵扯到Firefox国密证书、跨语言验签、PDF签名这类实际业务场景积累了不少一手经验今天花点时间把整个封装过程、设计思路、以及那些翻车的细节都整理出来给后面要做类似工作的朋友一个参考。这篇文章不是纯理论讲算法而是围绕“怎么在实际项目里把国密算法用起来”这条主线覆盖算法原理选型、Java和JS双端封装实现、SM2签名验签的坑、Firefox证书适配、以及各种典型报错的排查过程。无论你是刚开始接触国密算法的初学者还是正在带团队做等保、商密改造的技术负责人应该都能从中找到可以直接抄作业的内容。1. 内容整体设计与思路拆解1.1 三个算法各管一段先想清楚再动手国密算法不是“一个算法”而是一整套密码体系SM2、SM3、SM4各自扮演的角色差异非常大刚接触的人很容易搞混。SM4是对称分组密码算法密钥长度128位、分组长度128位本质上和国际上的AES差不多适合做批量数据加密比如文件加密、接口报文加密这种场景。SM3是密码杂凑算法输出256位摘要用于完整性校验、密码存储校验等对应的是SHA-256的角色。SM2则是基于椭圆曲线的非对称算法既能做加密解密也能做数字签名和密钥交换对标的是RSA和ECDSA。在设计封装层的时候我首先想清楚了一件事底层算法库已经有很多成熟实现不需要自己造轮子真正要封装的是“与业务无关、但每个项目都会遇到的公共逻辑”。这些公共逻辑包括密钥格式的统一转换、跨语言的编码对齐、不同库之间密文布局的适配、以及一套面向业务方傻瓜式调用的API。理想状态下业务方调用封装层时不应该感知底层算法细节只需要传入参数、拿到结果签名验签、加解密的正确性由封装层保证。1.2 封装不是越大越好接口粒度要卡在两个维度我见过不少团队做国密封装把几十个方法塞进一个类里看着功能齐全实际用起来谁都不知道该调哪个。这次封装我在接口粒度和类型安全两个维度上做了权衡。接口粒度上拆成了三层底层算法适配层负责对接不同来源的算法库实现后续可替换核心服务层提供加密、解密、签名、验签、摘要五个基础动作业务集成层针对具体场景做组合比如“传输密钥封装业务数据加密”这种两步操作直接封装成一个方法。类型安全上密钥、签名结果、密文这些敏感数据统一封装成独立对象避免到处传裸String导致格式混乱。比如密钥对象内部持有公钥、私钥、算法标识、格式标识四个属性序列化和反序列化都由密钥对象自己管理这样不管底层格式是DER、PEM还是HEX字符串外层使用方完全不需要关心。1.3 底层选型Java侧BouncyCastleJS侧上双保险Java端当前最成熟的国密算法实现就是BouncyCastle简称BC已经完整支持SM2、SM3、SM4而且在JDK 1.8到JDK 17上表现都稳定。我这次把版本锁定在bcprov-jdk18on-1.78.1注意BC的包名从1.78开始调整为jdk18on老项目如果之前用的是bcprov-jdk15on系列升级时需要注意兼容性。JS端的选择纠结了一段时间。纯JS实现用sm-crypto库腾讯团队开源、维护频率尚可覆盖SM2、SM3、SM4的加解密和签名验签能力用起来简单另外一条路是走Web Crypto API配合第三方扩展包但Web Crypto原生不直接支持国密需要额外引入polyfill兼容性和成熟度都存在不确定性。最终我的方案是底层抽象了一个CryptoProvider接口默认实现走sm-crypto同时预留了未来接入Web Crypto的扩展点。如果业务方明确要求高性能或运行在特殊浏览器环境中可以替换实现而不影响上层代码。2. 核心细节解析与实操要点2.1 SM2加密与签名各自管什么很多人栽在这里这是国密算法使用中最容易混淆的问题热搜词里提到的“PDF文件是使用SM2做加密还是签名”本质上问的就是这一点。SM2加密的目的是保证机密性发送方用接收方的公钥加密数据只有持有对应私钥的接收方才能解开。SM2签名的目的是保证真实性和不可抵赖性发送方用自己的私钥对摘要做签名接收方用发送方的公钥验证签名确认数据确实来自该发送方且未被篡改。PDF文件的实践做法通常是两者结合文件本身用SM4做对称加密以提升效率SM2用来加密SM4的对称密钥数字信封同时用SM2对文件摘要做数字签名以确认来源和完整性。加密解决的是“别人看不懂”签名解决的是“确实是这个人发的、内容没被改过”各管各的事谁都不能替代谁。2.2 密文布局C1C3C2还是C1C2C3一个字节的顺序能让你加解密半天SM2密文格式在国家标准GM/T 0009中定义的是C1C3C2也就是椭圆曲线点C1、摘要值C3、密文C2这个顺序。但这个标准并没有被所有实现统一遵守比如某些老版本基于第三方库的实现默认输出C1C2C3顺序JavaScript端sm-crypto库遵循的是C1C3C2Java端BouncyCastle底层默认输出的也是C1C3C2但如果有人改过配置或者用的是较早版本就可能是另一种布局。跨端互操作时这个问题非常隐蔽因为报错往往是“Invalid point coordinates”或者解密后出现乱码表面上和密文顺序毫无关系。我的做法是在封装层写了一个统一的密文格式探测和转换工具先判断C1的长度根据椭圆曲线参数可以精确推算再根据长度定位C3和C2的边界最后统一重排成C1C3C2标准格式。这个工具代码不长但能解决大部分跨语言痛点。2.3 SM3摘要的“时效性”问题决定了你的签名方案怎么设计SM3本质上和SHA-256类似输出固定32字节摘要。但直接用SM3对完整原文做摘要再配合非对称签名在流式计算场景下存在一个容易被忽略的问题SM3是直接对原文做杂凑没有密钥参与任何人拿到原文都能计算同样的摘要所以它只能用于完整性校验不能承担消息认证码MAC的功能。在需要同时保证完整性和来源真实性的接口场景里正确做法是用SM2签名直接覆盖原文摘要或者用HMAC-SM3构造带密钥的消息认证码。HMAC-SM3的具体构造方法是将SM3作为基础杂凑函数嵌入HMAC框架ipad/opad填充不是简单地“先算SM3再加盐”这一点在对接第三方国密网关时经常成为兼容性分歧点。2.4 SM4模式选择ECB、CBC还是GCM我最终推荐这么做SM4本身是分组密码分组大小16字节所以必然涉及模式选择。ECB模式虽然最简单但相同明文会生成相同密文容易泄露数据模式特征在真实业务系统中我强烈不建议使用它只适合极少数测试场景。实际项目中主要考虑两种模式。CBC模式配合随机生成16字节IV算法简单、性能稳定兼容性最好适合传统接口对接GCM模式则额外提供认证能力密文被篡改后接收方能够发现安全性更上一个台阶但部分老的国密网关未必支持。这次封装我做了两套实现默认参数走CBC随机IVPKCS7填充同时也提供GCM模式的配置开关。需要特别提醒的是IV每次加密都必须重新生成随机值如果固定IV再配合CBC模式等于把安全性削成了纸糊的盾。3. 实操过程与核心环节实现3.1 Java侧封装依赖引入与Provider注册Java端首先要解决的是BouncyCastle的引入和初始化。Maven项目直接在pom.xml中添加依赖dependency groupIdorg.bouncycastle/groupId artifactIdbcprov-jdk18on/artifactId version1.78.1/version /dependencyProvider注册这一步看似简单但有个重复加载的坑。如果你在多个类加载器或动态部署环境中初始化Provider有可能抛出类冲突或重复注册异常。一个相对稳妥的做法是用单例模式控制初始化逻辑并且在Provider已经存在时跳过重复添加public class CryptoInitializer { private static volatile boolean initialized false; public static void ensureInitialized() { if (initialized) { return; } synchronized (CryptoInitializer.class) { if (initialized) { return; } if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) null) { Security.addProvider(new BouncyCastleProvider()); } initialized true; } } }3.2 Java侧SM2加解密、签名验签的封装示例初始化Provider之后才能调用BC的SM2算法实现。密钥生成的部分可以这样处理import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPublicKey; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.jce.spec.ECParameterSpec; import org.bouncycastle.crypto.params.ECDomainParameters; import org.bouncycastle.jcajce.spec.SM2ParameterSpec; import org.bouncycastle.jcajce.provider.asymmetric.util.EC5Util; import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPrivateKey; import org.bouncycastle.jcajce.provider.symmetric.SM4; import org.bouncycastle.jce.spec.ECNamedCurveGenParameterSpec; import java.security.KeyPairGenerator; import java.security.KeyPair; import java.security.Security; CryptoInitializer.ensureInitialized(); KeyPairGenerator generator KeyPairGenerator.getInstance(EC, BouncyCastleProvider.PROVIDER_NAME); generator.initialize(new ECNamedCurveGenParameterSpec(sm2p256v1), new SecureRandom()); KeyPair keyPair generator.generateKeyPair(); BCECPublicKey publicKey (BCECPublicKey) keyPair.getPublic(); BCECPrivateKey privateKey (BCECPrivateKey) keyPair.getPrivate(); String publicKeyHex Hex.toHexString(publicKey.getQ().getEncoded(false)); String privateKeyHex Hex.toHexString(privateKey.getD().toByteArray());注意这里生成的是密钥对实际生产场景中密钥的存储、备份、分发需要单独设计不能像示例里这样只丢一个Hex字符串。密钥生成之后要配合国密标准推荐的SM2密钥等JSON格式或PEM格式进行序列化方便跨平台传递。SM2加解密封装相对直接BC里封装好的SM2Engine可以直接使用默认模式是C1C3C2输出正好符合国标。需要注意填充模式如果原文长度不等于密钥协商时的分组要求要配合SM2InputStream或者KDF做长度适配实际项目中更常见的是对原文先做对称加密再对对称密钥做SM2加密很少直接用SM2硬扛大文件。签名验签的封装使用Signature类配合SM2ParameterSpec传入用户的ID参数国密标准中默认ID为1234567812345678但正式商密场景通常需要传真实用户IDSignature signer Signature.getInstance(SM3withSM2, BouncyCastleProvider.PROVIDER_NAME); SM2ParameterSpec sm2ParameterSpec new SM2ParameterSpec(userId.getBytes(StandardCharsets.UTF_8)); signer.setParameter(sm2ParameterSpec); signer.initSign(privateKey); signer.update(data); byte[] signature signer.sign();验签时对应使用initVerify(publicKey)参数保持一致。这个userID参数的坑在跨语言对接时相当常见两边只要有一个没传ID或用默认值验签结果就会对不上。3.3 Java侧SM3与SM4的封装示例SM3在BC里已经有原生摘要实现和SHA-256的调用方式一致MessageDigest digest MessageDigest.getInstance(SM3, BouncyCastleProvider.PROVIDER_NAME); byte[] hash digest.digest(rawData);SM4加解密采用CBC模式加PKCS7填充BC同样提供现成支持KeyGenerator sm4Gen KeyGenerator.getInstance(SM4, BouncyCastleProvider.PROVIDER_NAME); sm4Gen.init(128, new SecureRandom()); SecretKey sm4Key sm4Gen.generateKey(); Cipher cipher Cipher.getInstance(SM4/CBC/PKCS7Padding, BouncyCastleProvider.PROVIDER_NAME); IvParameterSpec ivSpec new IvParameterSpec(ivBytes); cipher.init(Cipher.ENCRYPT_MODE, sm4Key, ivSpec); byte[] encrypted cipher.doFinal(data);注意Cipher.getInstance的第一个参数拼接方式是“算法/模式/填充”这三段必须完全匹配BC里注册好的转换字符串大小写出错会直接抛NoSuchAlgorithmException。另外SM4的密钥长度固定为16字节128位初始化时传256位会直接抛异常。3.4 JS侧封装核心代码与分析JS端我选了sm-crypto作为默认实现它的API设计得比较简单SM2加解密、签名验签、SM3摘要、SM4加解密都有现成方法。SM4的CBC模式加解密这样封装const sm4 require(sm-crypto).sm4; // 固定使用CBC模式IV随机生成 const iv new Uint8Array(16); crypto.getRandomValues(iv); const secureKey 0123456789abcdeffedcba9876543210; // 示例密钥实际应从密钥管理系统获取 const encryptResult sm4.encrypt(plainText, secureKey, { mode: cbc, iv: bytesToHex(iv), padding: pkcs#7 });SM2签名验签部分sm-crypto默认输出的是r||s拼接的签名串而Java端BC输出的签名结果也是r||s大端字节拼接格式上基本一致。唯一要注意的是SM2签名中用户ID的处理sm-crypto默认使用1234567812345678如果Java端签名时传入了真实用户IDJS端验签时也必须使用相同的ID否则验签会失败。3.5 跨语言互通的两个关键约定把Java和JS的封装实现放在一起看最关键的是提前定好三个约定否则后期联调会极其痛苦。第一是编码约定。密文、签名、密钥统一使用十六进制字符串传输不用Base64。两者都能转换但不同库对Base64的编码无填充、URL-safe等变体支持差异较大统一Hex把变体环境对整个开发组的要求降到最低。第二是SM2密文布局。强制统一为C1C3C2无论底层库默认输出什么封装层都要做格式转换让上层永远只接触标准格式。第三是SM2用户ID的传递。封装层的签名验签接口必须提供userId参数Java端的SM2ParameterSpec和JS端sm-crypto的userIdParameter或默认值保持一致避免“开发环境都通过、一把证书倒入生产就验签失败”的经典事故。3.6 国密证书在Firefox里的适配实践聊完了算法代码再来说说证书层的东西。SM2国密证书在Firefox里的支持进度比Chrome阵营要好基于Firefox 115以上版本在开启security.cert_pinning.enforcement_level和security.enterprise_roots.enabled两个偏好之后再导入国密根证书就能正常访问部署了国密SSL证书的站点。实际操作步骤参考如下从CA机构获取国密根证书PEM格式和对应的SM2签名证书、SM2加密证书。打开Firefox进入“设置 - 隐私与安全 - 证书 - 查看证书”。在“证书颁发机构”选项卡中点击“导入”选中根证书PEM文件。重启浏览器。访问国密站点点击地址栏左侧的锁形图标确认证书链是否完整、是否报“证书颁发机构不受信任”之类的错误。需要特别注意有一些国产linux版本的火狐国内定制版默认会多一层安全策略即使用户手动导入了根证书也存在被策略拦截的情形。碰到这种问题优先检查浏览器版本、所属渠道、企业策略配置三项不要一上来就怀疑证书生成流程。4. 常见问题与排查技巧实录4.1 跨语言加解密失败的第一排查清单这类问题遇到得最多而且每次的根因排列组合几乎都一样。我直接把排查顺序整理成表格联调时按顺序核查就行排查项核对方法典型错误表现密钥格式确认两侧密钥Hex或PEM内容一致注意DER编码的04开头未压缩点格式Invalid point coordinatesSM2密文布局检查C1C3C2还是C1C2C3统一用标准C1C3C2解密后乱码或Length mismatch用户IDJava侧与JS侧签名验签的userId必须完全一致Signature verification failed填充模式SM4统一PKCS#7确认底层库是否默认NoPaddingGiven final block not properly padded编码方式确认传输过程用Hex且没有误转大小写消息摘要不匹配实际联调经验是超过80%的跨语言国密问题出在这五项的其中一项先把这五项固定下来再去查代码逻辑会省非常多的排查时间。4.2 SM3的摘要长度与拼接问题很多人在封装SM3时会写一个“拼接版摘要”方法也就是把多个字段拼接成一个字符串再整体计算SM3这个思路本身没问题但字段分隔符如果不统一不同端拿到的原始字符串可能不同。更稳的方案是做一个基于流式的SM3更新方法按字段顺序依次调用update字段之间不加显式分隔符两侧保持相同的拼接顺序即可。不要写成“SM3(field1 | field2)”这种过度依赖分隔符的版本一旦某个字段内部包含分隔符数据组织和摘要结果都会出问题。4.3 BC库升级带来的“隐形不兼容”从BC 1.70升级到1.78之后很多老代码在调用SM2Engine时会遇到类名变化或者构造方法签名变化比如部分旧版内部类被重新组织。升级后第一时间跑一遍算法自测用例把企业内部的加解密、签名验签、摘要计算全部回归一遍不留死角。另外如果同一个应用里同时存在多个版本的BC jar包经常发生在多个中间件依赖冲突时JVM会按Class-Path加载优先级命中其中一个可能导致算法行为不一致。检查依赖树确认只有单一BC版本必要时在maven-shade或gradle resolutionStrategy里强制统一版本。4.4 算法实现上的边界值注意空数据与大文件使用SM3时对空字符串的计算结果是固定的1ab21d8355cfa17f8e61194831e81a8f22bec8c728fefb747ed035eb5082aa2b也就是国标测试向量封装时要把这个值加入自动化测试用例。大文件SM2加密场景不要直接塞进内存SM2适合加密对称密钥文件本体用SM4处理如果非要直接做SM2加密大文件除了效率问题以外还可能触发底层实现长度限制的异常。4.5 国密算法“逆向”话题的正确打开方式热搜里“国密算法逆向”这个说法稍微有点标题党但实际工作中确实会出现在对接第三方系统、排查兼容性问题时需要阅读反编译代码的场景。国密算法本身是公开的真正需要保护的永远是用密钥管理体系保护的私钥和不落地的敏感数据。分析现有实现时优先看密钥、证书、协议交互日志这几个层面对纯社区代码库同步引入时也注意检查许可证合规问题。5. 写在最后的几个实操建议这套封装在内部跑了有两个多月稳定性和互操作性基本达到预期。我个人在实际操作中的体会是国密算法本身并不难复杂的是它牵扯到的标准差异、跨语言兼容和各家浏览器支持策略。有一个教训很深国密证书在Firefox里看起来一切正常但在某些内网安全软件改动了浏览器策略之后就会出现不可信证书的报错这种环境问题排查起来既耗时又考验对策略配置的熟悉程度。最后再分享一个提升效率的小技巧建议在团队内部维护一份“国密互操作测试向量集”涵盖SM2加解密、SM2签名验签、SM3摘要、SM4加解密四个维度的标准测试向量每次升级依赖库、调整封装层或对接新系统时先跑一遍向量集。这比翻标准文档逐条核对快得多也是防止算法实现“悄悄被改坏”的最有效防线。后续有需要的话我打算把这个向量集和相关工具脚本整理出来单独分享也欢迎大家在实际对接中遇到有意思的坑来一起交流。本文还有配套的精品资源点击获取