
简介面向需要集成国密算法或学习密码学实现的开发者这份资源提供了结合OpenSSL的SM2、SM3、SM4三套算法C语言源码覆盖非对称加密、哈希摘要与对称加密三类典型密码场景。压缩包内共17个文件含7个C源文件与4个头文件前者给出核心算法实现后者提供函数接口声明另有3个DSP与3个DSW工程文件可在Visual C环境中直接打开编译验证免去手动配置的繁琐。整个压缩包仅26KB代码紧凑精炼适合逐行研读或快速移植进安全通信、数字签名等业务系统。目前已有281人浏览学习具备较好的实践参考价值。通过研读源码可理解国密算法的大数运算、椭圆曲线点乘、消息填充、分组模式等关键细节以及OpenSSL库的接口调用方式为后续二次开发或自主实现国产密码模块提供直接示例。1. SM2、SM3、SM4 三件套的 C 语言实现难的不是算法而是接口SM2_SM3_SM4_C语言实现几乎是每个做国产化改造的工程师都会搜一次的组合。SM3 是 256 位杂凑算法SM4 是 128 位分组密码SM2 是基于椭圆曲线的公钥体系数字签名、密钥交换、加密三者合称国密三件套。真正的问题从来不是有没有 C 实现——GmSSL、OpenSSL 3.x 自带 provider 里都有现成代码——而是怎么把这三件套干净地接进自己的工程嵌入式设备跑裸机版本服务端走 OpenSSL EVP 接口数据库国密改造用 SM2 做身份认证、SM4 做列加密。拿到这类资源包先别急着编下面按算法结构 → 最小实现 → EVP 接入 → 联调排错往下推适合要把国密写进代码、又得跟客户对着测试向量逐字节核对的人。2. SM3 与 SM4 的 C 实现从分组结构到可复现测试向量2.1 SM3 的消息扩展与压缩函数先看常数再看轮函数SM3 是 Merkle–Damgård 结构的杂凑算法512 位一个分组输出 256 位。填充规则和 SHA-256 几乎一致消息后补 0x80再补 0 直到长度对 512 取模等于 448最后接 64 位大端消息长度。两个分组含填充就走两次压缩函数三个分组就走三次。网上流传的 sm3 hash algorithm block diagram 把流程画得很清楚但按图实现时最容易漏掉消息扩展里 W 的生成这一步漏了压缩函数全盘皆错。初始化向量和轮常数是写死的第一批常数必须和标准逐字节一致/* SM3 初始向量 IV与标准 GM/T 0004-2012 附录一致 */ static const uint32_t SM3_IV[8] { 0x7380166f, 0x4914b2b9, 0x172442d7, 0xda8a0600, 0xa96f30bc, 0x163138aa, 0xe38dee4d, 0xb0fb0e4e }; /* 轮常数j 16 与 j 16 两段取值不同 */ static inline uint32_t sm3_t(uint32_t j) { return j 16 ? 0x79cc4519u : 0x7a879d8au; }参数说明SM3_IV与前 8 个素数平方根的小数部分相关sm3_t()在 0..15 轮取 0x79cc451916..63 轮取 0x7a879d8a。写实现时最常犯的错是把两段常数写反症状是第一个分组正常、第二个分组开始摘要全错。2.2 SM3 压缩函数的 64 轮循环怎么落成 C 代码消息扩展先把 16 个字 W[0..15] 扩成 68 个 W 和 64 个 W随后对 A..H 八个 32 位寄存器做 64 轮迭代。核心是三个位运算宏和一轮主循环#define ROL32(x, n) (((x) (n)) | ((x) (32 - (n)))) #define P0(x) ((x) ^ ROL32((x), 9) ^ ROL32((x), 17)) #define P1(x) ((x) ^ ROL32((x), 15) ^ ROL32((x), 23)) /* 64 轮压缩循环W[j]、Wp[j] 来自消息扩展 */ for (j 0; j 64; j) { uint32_t SS1 ROL32(ROL32(A, 12) E ROL32(sm3_t(j), j), 7); uint32_t SS2 SS1 ^ ROL32(A, 12); uint32_t TT1 FF(A, B, C, j) D SS2 Wp[j]; uint32_t TT2 GG(E, F, G, j) H SS1 W[j]; D C; C ROL32(B, 9); B A; A TT1; H G; G ROL32(F, 19); F E; E P0(TT2); }逻辑说明FF在 j 16 时是A ^ B ^ C之后变成(A B) | (A C) | (B C)GG对应E ^ F ^ G与(E F) | (~E G)。Wp[j]是 W它的生成要经过 P1 置换和 16 个历史字的异或建议单独写一个sm3_expand()函数而不是挤在轮循环里。这段代码不优化直接对应标准里的记号方便对着 GM/T 0004-2012 逐行核对。2.3 SM4 的 32 轮迭代与密钥扩展S 盒决定一切SM4 是 128 位分组、128 位密钥的 32 轮迭代型分组密码国标编号 GM/T 0002-2012。每轮用 32 位轮密钥 rk 对状态做异或 S 盒代换 线性变换线性变换 L(B) B ^ ROL32(B,2) ^ ROL32(B,10) ^ ROL32(B,18) ^ ROL32(B,24)。密钥扩展把 FK 与密钥异或后用同一套 S 盒再生成 32 个轮密钥static const uint32_t SM4_FK[4] { 0xa3b1bac6, 0x56aa3350, 0x677d9197, 0xb27022dc }; static const uint32_t SM4_CK[32] { 0x00070e15, 0x1c232a31, 0x383f464d, 0x545b6269, /* 每 4 个一组等差 0x07070707共 32 个完整表查标准 */ }; static uint32_t sm4_lt(uint32_t t) { return t ^ ROL32(t, 2) ^ ROL32(t, 10) ^ ROL32(t, 18) ^ ROL32(t, 24); } static uint32_t sm4_tau(uint32_t x) { return ((uint32_t)SM4_SBOX[(x 24) 0xff] 24) | ((uint32_t)SM4_SBOX[(x 16) 0xff] 16) | ((uint32_t)SM4_SBOX[(x 8) 0xff] 8) | ((uint32_t)SM4_SBOX[(x ) 0xff]); } /* 密钥扩展K[i4] K[i] ^ T(K[i1] ^ K[i2] ^ K[i3] ^ CK[i]) */ static void sm4_key_expand(const uint8_t key[16], uint32_t rk[32]) { uint32_t k[4]; for (int i 0; i 4; i) k[i] be32dec(key 4 * i) ^ SM4_FK[i]; for (int i 0; i 32; i) { rk[i] k[i 3] ^ sm4_lt(sm4_tau(k[(i 1) 3] ^ k[(i 2) 3] ^ k[(i 3) 3] ^ SM4_CK[i])); } }参数说明be32dec负责把 4 字节大端读成 uint32国密算法全链路大端和网络字节序一致。rk[i] k[i 3] ^ ...是借用了密钥扩展每轮原地滚动 4 个寄存器的写法等价于标准里的 K_{i4} 递推。SM4_SBOX是 256 项静态表实现时直接从标准附录抄任何改动都会让测试向量对不上。提示自己写实现只用来学习和做交叉验证生产环境优先走 OpenSSL provider 或经审计的国密库。查表型 S 盒实现若用密钥做数组下标容易被缓存时序侧信道盯上。2.4 用两组标准测试向量把实现钉死无论中间怎么写最终以标准里的测试数据为准。SM3 用两条SM4 用一条算法输入输出SM3abc66c7f0f4 62eeedd9 d1f2d46b dc10e4e2 4167c487 5cf2f7a2 297da02b 8f4ba8e0SM3abcd 重复 16 次共 64 字节debe9ff9 2275b8a1 38604889 c18e5a4d 6fdb70e5 387e5765 293dcba3 9c0c5732SM4key 与明文同为 0123456789abcdeffedcba9876543210681edf34 d206965e 86b3e94f 536e4246第二行 SM3 向量专门用来暴露只处理一个分组的实现缺陷SM4 那组是国标公开的第一个例子明文、密钥、密文是同一串字节跑不对说明 S 盒或轮密钥顺序有误。测试程序里把它写成宏断言每次编译都执行一遍。交叉验证更简单openssl dgst -sm3对同一文件算摘要openssl enc -sm4-ecb -K对同一明文加密两边结果一致就说明字节序和填充没歪。SM4 的分组模式沿用通用分组密码那套ECB、CBC、CFB、OFB、CTR工程上极少用 ECBCBC 必须配 PKCS#7 填充和随机 IVIV 每次加密都要重新生成。3. SM2 数字签名与加密ZA 杂凑、曲线参数和 OpenSSL EVP 接入3.1 SM2 的曲线参数与 ZA 计算签名前必须算的一次杂凑SM2 使用 256 位素域上的椭圆曲线方程 y² x³ ax b 定义在素域 p 上p、a、b、基点 G、阶 n 全部固定由 GB/T 32918 规定。这几个参数写进工程时要逐字节核对我见过不止一次把 a 填成 p 导致全部密钥无效的案例参数值十六进制32 字节pFFFFFFFE FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF 00000000 FFFFFFFF FFFFFFFFaFFFFFFFE FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF 00000000 FFFFFFFF FFFFFFFCb28E9FA9E 9D9F5E34 4D5A9E4B CF6509A7 F39789F5 15AB8F92 DDBCBD41 4D940E93Gx32C4AE2C 1F198119 5F990446 6A39C994 8FE30BBF F2660BE1 715A4589 334C74C7GyBC3736A2 F4F6779C 59BDCEE3 6B692153 D0A9877C C62A4740 02DF32E5 2139F0A0nFFFFFFFE FFFFFFFF FFFFFFFF FFFFFFFF 7203DF6B 21C6052B 53BBF409 39D54123SM2 的签名和加密都依赖一个叫 ZA 的 256 位杂凑值它把用户可辨别标识 IDA 和曲线参数一起送进 SM3。这是 SM2 和普通 ECDSA 最大的区别也是跨端联调第一个翻车点——两端 ID 字符串不一致签名永远验不过。/* ZA SM3(ENTL || IDA || a || b || Gx || Gy || xA || yA) * ENTL: IDA 的比特长度2 字节大端 */ uint16_t bitlen (uint16_t)(ida_len * 8); uint8_t entl[2] { (uint8_t)(bitlen 8), (uint8_t)(bitlen 0xff) }; EVP_MD_CTX *md EVP_MD_CTX_new(); EVP_DigestInit_ex(md, EVP_sm3(), NULL); EVP_DigestUpdate(md, entl, 2); EVP_DigestUpdate(md, ida, ida_len); EVP_DigestUpdate(md, sm2_a, 32); /* a 的 32 字节大端 */ EVP_DigestUpdate(md, sm2_b, 32); EVP_DigestUpdate(md, sm2_gx, 32); EVP_DigestUpdate(md, sm2_gy, 32); EVP_DigestUpdate(md, pub_x, 32); /* 公钥 xA */ EVP_DigestUpdate(md, pub_y, 32); /* 公钥 yA */ EVP_DigestFinal_ex(md, za, za_len);逻辑说明所有域元素都是 32 字节大端没有压缩点前缀顺序严格按 a、b、Gx、Gy、xA、yA。注意是公钥参与杂凑所以 ZA 跟密钥对绑定换密钥必须重算。联调时对方如果没给 ID先按国标默认 ID 1234567812345678ASCII16 字节试这是最常见的约定。3.2 用 OpenSSL 生成 SM2 密钥对和签名EVP 一条路走通OpenSSL 3.x 把 SM2 当作 EC 曲线的特殊 NID 处理密钥生成走EVP_PKEY_CTX_new_id(EVP_PKEY_EC, NULL)参数组名指定为 SM2openssl ecparam -name SM2 -genkey -out sm2_key.pem openssl pkey -in sm2_key.pem -text -noout # 查看曲线名与公钥密钥生成好之后签名分三步初始化摘要上下文、设置参与人标识 ID、更新消息并签出。OpenSSL 3.x 的 EVP 层会自动计算 ZA不需要手动把 SM3 结果拼进消息EVP_MD_CTX *mctx EVP_MD_CTX_new(); EVP_PKEY_CTX *pctx NULL; if (EVP_DigestSignInit(mctx, pctx, EVP_sm3(), NULL, sm2_pkey) 0) exit(1); /* 真实工程请替换为错误处理 */ /* 设置 ZA 计算用的 IDA缺省也是 1234567812345678 */ EVP_PKEY_CTX_set1_id(pctx, (const unsigned char *)1234567812345678, 16); EVP_DigestSignUpdate(mctx, msg, msg_len); size_t sig_len 0; EVP_DigestSignFinal(mctx, NULL, sig_len); /* 第一次调用取签名长度 */ unsigned char *sig malloc(sig_len); EVP_DigestSignFinal(mctx, sig, sig_len);参数说明EVP_PKEY_CTX_set1_id设置的就是 ZA 里的 IDA必须在SignInit之后、Update之前调用签名结果是 ASN.1 DER 编码r、s 各一个 INTEGER长度约 70 字节不是国标里的纯 64 字节 r||s。要转成 64 字节布局用d2i_ECDSA_SIG拆出 r、s 再各补零到 32 字节。验签走EVP_DigestVerifyInit/Update/Final参数完全对称。3.3 SM2 加密的 C1C3C2 排列和 AES 的差别一张表看清SM2 加密输出三段C1 是临时公钥点、C3 是 SM3 校验值、C2 是密文。最终发布标准 GB/T 32918.4-2016 采用 C1C3C2 排列但早期草案是 C1C2C3OpenSSL 3.x 实现按 C1C3C2。和 Java 老库、旧设备对接时如果密文能解但 SM3 校验老失败第一反应先核对排列顺序而不是改密钥。国密和通用算法混用时经常被问 AES256、SM2、SM4 什么关系一张表说清楚算法类型密钥/参数典型用途SM2椭圆曲线公钥算法256 位素域签名、密钥交换、加密数字信封、身份认证、国密 TLSSM4对称分组密码128 位密钥、128 位分组数据批量加密、数据库列加密AES256对称分组密码256 位密钥、128 位分组通用数据加密生态与性能最好三者不是替代关系SM2 管这把钥匙是谁的SM4 管数据本身不泄露AES256 是生态成熟的对标物。国密改造常见的做法是 SM2 做签名和密钥协商派生出的会话密钥交给 SM4 加密业务数据对应国密 TLS 标准里 ECC_SM4_GCM_SM3 这类套件的完整链路。做数据库国密测试时比如 OceanBase 的 SM4 列加密加解密一般由连接驱动层完成应用层再套一层 SM4 就是双重加密验数时先确认数据落库的形态再决定要不要动代码。4. 把三件套接进 OpenSSL环境检查、EVP 单测和四个必踩的坑4.1 先确认当前 OpenSSL 到底带不带 SM2/SM3/SM4OpenSSL 3.0 起默认 provider 内置 SM3、SM4 的常用模式SM2 作为命名曲线也能直接生成密钥。拿到一台机器先跑四条命令比翻文档快openssl version openssl list -digest-algorithms | grep -i sm3 openssl list -cipher-algorithms | grep -i sm4 openssl ecparam -list_curves | grep -i sm2判断逻辑三条 grep 都有输出说明默认 provider 已加载如果 digest 和 cipher 有输出但list_curves里没有 SM2通常不是版本问题而是构建时裁剪了 EC 相关组件。openssl rand -hex 32用来快速生成 256 位会话密钥顺便验证 RNG 是否正常这是最容易忽略的一步。自家工程若是静态链 OpenSSL头文件和库版本错开时会出现openssl version mismatch. built against 30000020, you have 30500060这类告警原因不是密码算法本身而是编译和运行用了两套 OpenSSL优先用pkg-config统一头文件与库的路径而不是手工-I乱指。4.2 最小 EVP 调用SM3 摘要和 SM4-CBC 加解密用 EVP 接口的好处是代码与具体实现解耦默认 provider 自动选最优实现。先看 SM3 对内存块做摘要的最小代码unsigned char md[EVP_MAX_MD_SIZE]; unsigned int md_len 0; EVP_MD_CTX *ctx EVP_MD_CTX_new(); EVP_DigestInit_ex(ctx, EVP_sm3(), NULL); EVP_DigestUpdate(ctx, data, data_len); EVP_DigestFinal_ex(ctx, md, md_len); /* md_len 固定为 32 */ EVP_MD_CTX_free(ctx);SM4-CBC 加解密同样三步初始化、设置 key/iv、分段更新EVP_CIPHER_CTX *cctx EVP_CIPHER_CTX_new(); unsigned char key[16], iv[16]; /* key、iv 必须 16 字节IV 每次加密都要重新生成不要固定 */ EVP_EncryptInit_ex(cctx, EVP_sm4_cbc(), NULL, key, iv); EVP_CIPHER_CTX_set_padding(cctx, 1); /* PKCS#7默认开启 */ unsigned char out[128], in[64]; int outl 0, total 0; EVP_EncryptUpdate(cctx, out total, outl, in, sizeof(in)); total outl; EVP_EncryptFinal_ex(cctx, out total, outl); total outl; /* 填充块只在这里出现 */ EVP_CIPHER_CTX_free(cctx);参数说明是出错重灾区SM4 的 key、iv、分组都是 16 字节不是 32。EVP_EncryptFinal_ex在开启填充时会把末尾不足 16 字节的部分补出来输出缓冲区要预留in_len 16。解密时若EVP_DecryptFinal_ex返回失败八成是 key/iv 不一致或密文 hex 解码出错先把 key、iv、密文分别打成十六进制字符串逐项比对不要盯着报错信息猜。联调时也别把生产环境的 key 丢给在线加解密站点验证自己用openssl enc -sm4-cbc在本地跑一遍最稳。4.3 跨端联调四个必踩的坑按出现频率排现象根因处理SM2 验签失败换端就错ZA 里 IDA 不一致或公钥 xA/yA 顺序反了双方把 ID 字符串和公钥 hex 发出来逐字节比对密文能解开但 SM3 校验失败SM2 密文是 C1C2C3 老布局确认按 GB/T 32918.4-2016 的 C1C3C2 解析Java 端报 NoSuchAlgorithmException: SM4/CBC/PKCS7PaddingJDK 的 JCE 默认不含国密算法引入 BouncyCastle providerC 端保持 PKCS#7 对齐文件加密后长度莫名多出 16 字节流式加密后手抖又自己补了一遍填充以 EVP_EncryptFinal_ex 的返回值为准不要手工追加块除了这张表还有个检索层面的坑EtherCAT 协议里也有个 SM3指 Sync Manager 3同步管理器通道它的同步类型 0x0001 表示 mailbox 模式和国密 SM3 杂凑算法没有任何关系。搜 SM3 同步类型 时看到 0x0001、sm-sync 之类的词先确认语境否则容易把工业以太网的配置教程当成国密资料看。4.4 用 pkg-config 和运行时版本打印挡住版本错位版本类问题在国产化环境特别常见因为 OpenSSL 可能来自系统包、自编译、交叉编译工具链三处。常见做法是把整个工程的头文件和库路径统一交给 pkg-config# Makefile头文件与库来自同一安装前缀 CFLAGS : -O2 -Wall $(shell pkg-config --cflags openssl) LDLIBS : $(shell pkg-config --libs openssl) sm2_sm3_sm4_demo: sm2_sm3_sm4_demo.c $(CC) $(CFLAGS) -o $ $ $(LDLIBS) -lcrypto逻辑说明pkg-config --cflags openssl和--libs openssl必须解析到同一个安装前缀否则头文件是新版、运行库是旧版链接期不报错运行期才炸。Qt 工程里对应CONFIG link_pkgconfig加PKGCONFIG openssl不要手动写版本号。交叉编译 OpenSSL 时常见做法是./Configure linux-aarch64 --prefix$(SYSROOT)/usr这类显式指定 target 和安装路径避免宿主机头文件混进编译。程序启动时打一行版本信息到日志能把编译时和运行时不是同一个 OpenSSL这类问题从两小时排查缩短到两分钟#include openssl/opensslv.h #include openssl/crypto.h #include stdio.h int main(void) { printf(build: %s runtime: %s\n, OPENSSL_VERSION_TEXT, OpenSSL_version(OPENSSL_VERSION)); return 0; }5. 自检三件套把测试向量固化进代码联调前先跑一轮5.1 用断言把标准向量变成每次编译都执行的关卡三件套里任何一件改过一轮都应该能在一分钟内自证没写坏。常见做法是在工程里放一个crypto_selftest.c启动时或 CI 里执行断言失败直接退出#include assert.h #include string.h #include openssl/evp.h static void test_sm3_abc(void) { static const unsigned char expect[32] { 0x66, 0xc7, 0xf0, 0xf4, 0x62, 0xee, 0xed, 0xd9, 0xd1, 0xf2, 0xd4, 0x6b, 0xdc, 0x10, 0xe4, 0xe2, 0x41, 0x67, 0xc4, 0x87, 0x5c, 0xf2, 0xf7, 0xa2, 0x29, 0x7d, 0xa0, 0x2b, 0x8f, 0x4b, 0xa8, 0xe0 }; unsigned char md[32]; unsigned int n 0; EVP_MD_CTX *ctx EVP_MD_CTX_new(); EVP_DigestInit_ex(ctx, EVP_sm3(), NULL); EVP_DigestUpdate(ctx, abc, 3); EVP_DigestFinal_ex(ctx, md, n); assert(n 32 memcmp(md, expect, 32) 0); EVP_MD_CTX_free(ctx); }这段自检的要点有三层断言值直接抄标准测试向量不抄网上二次转发的输出assert只用于自检生产环境要替换成带日志的错误返回码SM4 的自检用 2.4 节那组 0123... 向量做一次加密再解密回环SM2 的自检做签名→验签回环再把 r、s 从 DER 拆成 64 字节与openssl pkeyutl的输出比对。联调前把自检结果发给对方按固定顺序核对三样东西SM4 的 key/iv/填充模式SM2 的密文排列C1C3C2 还是 C1C2C3SM3 参与的 ZA 用哪个 ID 字符串。三个对上再查字节序。最后一个技巧是给 SM2 加一个双端互签测试A、B 各自用对方的公钥验签通过之后再交换 Gx、Gy 确认不是两端各自实现了不同的曲线。SM2 的 p、a、b、n 是固定常数任何实现都不该让你手工配置这些参数——需要改 b 或 n 才能对上的所谓国密实现可以直接判定为不符合标准。本文还有配套的精品资源点击获取