C语言实现HMAC-SHA1:从原理到代码,搞定API接口签名
简介HMAC SHA1算法的C语言实现源码包面向需要在嵌入式或物联网环境中实现消息认证的开发者。该算法以密钥联合SHA1哈希函数用于核对数据完整性与来源常见于阿里云物联网套件等设备登录鉴权。压缩包共两个文件包含一个C源文件和一个头文件整体约3KB资源轻量便于集成C源文件提供了hmac_sha1_init、hmac_sha1_update、hmac_sha1_final及完整接口hmac_sha1覆盖初始化、消息更新、结果生成全过程头文件则声明了上下文结构与各函数原型可直接集成到已有C工程。目前已有三千三百一十三人学习下载借助这份代码读者既能获得可移植的HMAC SHA1模块也能通过实例理解密钥填充、异或处理、双重哈希等核心步骤便于快速部署到设备身份认证或通信安全增强场景。整体设计紧凑非常适合资源受限设备使用。1. 先说清楚HMAC-SHA1到底解决什么问题我在对接各种开放平台API的时候签名这块儿几乎绕不开HMAC-SHA1。最常见的场景就是你要调某个服务的接口对方要求你把请求参数加一个签名签名算法就是HMAC-SHA1密钥是平台分配给你的AppSecret。说白了HMAC-SHA1就是一把“带有密钥的哈希锁”它同时干了两件事——第一确认消息没被篡改过第二确认发消息的人真的持有那把密钥。很多人会把HMAC和普通的SHA1混淆这里必须说清楚SHA1本身只是一个哈希函数它不接收密钥任何知道原文的人都能算出同样的摘要但HMAC是Hash-based Message Authentication Code的缩写它在哈希外面套了一层密钥混合机制用ipad和opad两组常量把密钥跟消息“揉”在一起再做两次哈希。这个设计的巧妙之处在于即使SHA1本身存在碰撞攻击的漏洞HMAC-SHA1在实际应用中依然被认为是可行的因为攻击者即便找到了SHA1的碰撞也不代表他能绕过密钥。所以你在C语言里写HMAC-SHA1本质上不是自己发明算法而是要严格按RFC 2104的标准把“密钥填充—异或—拼接—两次哈希”这套流程实现出来。下面我从原理到代码一步步把这件事拆干净。2. 实现前必须搞懂的两个核心概念2.1 分组长度与密钥填充规则HMAC的底层哈希是分块处理的SHA1的分组长度是64字节。在开始计算之前密钥需要先做一个“预整形”如果密钥长度大于64字节先对密钥做一次SHA1哈希得到一个20字节的摘要然后把这个摘要作为实际使用的密钥如果密钥长度小于等于64字节保持原样最后把密钥补齐到64字节不足部分用0x00填充。这个填充过程有一个常见误区很多人以为密钥要像消息一样做PKCS#7填充其实不是。密钥不够长就是简单补零够长就先哈希再补零。补零后的64字节密钥分别与ipad0x36重复64次和opad0x5c重复64次做异或得到两组64字节的中间值。2.2 两次哈希的拼接顺序填充完密钥之后整个HMAC的核心流程就两句话第一次哈希SHA1((key ^ ipad) || message)第二次哈希SHA1((key ^ opad) || 第一次的20字节摘要)这里的“||”是字节串拼接。注意第二次哈希的输入是“opad异或后的64字节”加上“第一次的20字节摘要”总共84字节而SHA1的分组是64字节所以第二次哈希天然会分成两个分组来算。这个细节很重要因为很多人自己在C语言里实现时容易在第二次哈希的收尾上栽跟头——比如忘记把第一次的摘要当作消息的一部分继续喂给SHA1上下文而是重新初始化上下文只对20字节做哈希。3. C语言源码实现完整可编译版本3.1 SHA1核心实现先把SHA1自身实现了。为了方便嵌入到单片机或嵌入式内核里我没有依赖OpenSSL而是用纯C写了一份精简版。如果开发环境允许用OpenSSL也可以直接用OpenSSL的SHA1()函数替代但嵌入式场景往往没有这么方便所以自带实现是更通用的方案。#include stdio.h #include string.h #include stdint.h typedef struct { uint32_t state[5]; // A, B, C, D, E uint64_t bit_count; // 总比特数 uint8_t buffer[64]; // 待处理的数据块 } SHA1_CTX; #define ROTL32(value, bits) (((value) (bits)) | ((value) (32 - (bits)))) static void sha1_transform(SHA1_CTX *ctx, const uint8_t block[64]) { uint32_t w[80]; uint32_t a, b, c, d, e, f, k, temp; int i; for (i 0; i 16; i) { w[i] ((uint32_t)block[i * 4] 24) | ((uint32_t)block[i * 4 1] 16) | ((uint32_t)block[i * 4 2] 8) | ((uint32_t)block[i * 4 3]); } for (i 16; i 80; i) { w[i] ROTL32(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1); } a ctx-state[0]; b ctx-state[1]; c ctx-state[2]; d ctx-state[3]; e ctx-state[4]; for (i 0; i 80; i) { if (i 20) { f (b c) | ((~b) d); k 0x5A827999; } else if (i 40) { f b ^ c ^ d; k 0x6ED9EBA1; } else if (i 60) { f (b c) | (b d) | (c d); k 0x8F1BBCDC; } else { f b ^ c ^ d; k 0xCA62C1D6; } temp ROTL32(a, 5) f e k w[i]; e d; d c; c ROTL32(b, 30); b a; a temp; } ctx-state[0] a; ctx-state[1] b; ctx-state[2] c; ctx-state[3] d; ctx-state[4] e; memset(w, 0, sizeof(w)); } void sha1_init(SHA1_CTX *ctx) { ctx-state[0] 0x67452301; ctx-state[1] 0xEFCDAB89; ctx-state[2] 0x98BADCFE; ctx-state[3] 0x10325476; ctx-state[4] 0xC3D2E1F0; ctx-bit_count 0; } void sha1_update(SHA1_CTX *ctx, const uint8_t *data, size_t len) { size_t i, idx; idx (size_t)(ctx-bit_count / 8) % 64; ctx-bit_count (uint64_t)len * 8; for (i 0; i len; i) { ctx-buffer[idx] data[i]; if (idx 64) { sha1_transform(ctx, ctx-buffer); idx 0; } } } static void sha1_final(SHA1_CTX *ctx, uint8_t digest[20]) { uint8_t pad[64]; uint64_t bit_len ctx-bit_count; size_t idx (size_t)(bit_len / 8) % 64; size_t pad_len; memset(pad, 0, sizeof(pad)); pad[0] 0x80; if (idx 56) { pad_len 56 - idx; } else { pad_len 64 56 - idx; } sha1_update(ctx, pad, pad_len); uint8_t len_be[8]; for (int i 0; i 8; i) { len_be[i] (uint8_t)(bit_len (56 - i * 8)); } sha1_update(ctx, len_be, 8); for (int i 0; i 5; i) { digest[i * 4] (uint8_t)(ctx-state[i] 24); digest[i * 4 1] (uint8_t)(ctx-state[i] 16); digest[i * 4 2] (uint8_t)(ctx-state[i] 8); digest[i * 4 3] (uint8_t)(ctx-state[i]); } }这里注意sha1_final的消息填充无论当前缓冲区的数据长度多少先补一个0x80再补0直到长度对64取余等于56最后8字节是大端序的原始比特长度。这个“先补位再补长度”的顺序是SHA1规范里最容易写错的点。3.2 HMAC主函数与十六进制输出SHA1就绪之后HMAC就是纯粹的“搬运异或”了。下面这个hmac_sha1函数严格按照RFC 2104实现密钥长度和消息长度都支持任意输入。void hmac_sha1(const uint8_t *key, size_t key_len, const uint8_t *msg, size_t msg_len, uint8_t out[20]) { uint8_t k_pad[64]; uint8_t key_hash[20]; SHA1_CTX ctx; int i; memset(k_pad, 0, sizeof(k_pad)); if (key_len 64) { sha1_init(ctx); sha1_update(ctx, key, key_len); sha1_final(ctx, key_hash); memcpy(k_pad, key_hash, 20); } else { memcpy(k_pad, key, key_len); } // ipad 0x36 for (i 0; i 64; i) { k_pad[i] ^ 0x36; } sha1_init(ctx); sha1_update(ctx, k_pad, 64); sha1_update(ctx, msg, msg_len); sha1_final(ctx, out); // opad 0x5c for (i 0; i 64; i) { k_pad[i] ^ (0x36 ^ 0x5c); } sha1_init(ctx); sha1_update(ctx, k_pad, 64); sha1_update(ctx, out, 20); sha1_final(ctx, out); } void to_hex_string(const uint8_t *data, size_t len, char *hex_out) { static const char hex_digits[] 0123456789abcdef; for (size_t i 0; i len; i) { hex_out[i * 2] hex_digits[data[i] 4]; hex_out[i * 2 1] hex_digits[data[i] 0x0F]; } hex_out[len * 2] \0; } int main(void) { // 测试向量来自RFC 2202 Section 2, Case 1 uint8_t key[20] {0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b}; const char *msg Hi There; uint8_t digest[20]; char hex[41]; hmac_sha1(key, 20, (const uint8_t *)msg, strlen(msg), digest); to_hex_string(digest, 20, hex); printf(HMAC-SHA1: %s\n, hex); printf(期望值: b617318655057264e28bc0b6fb378c8ef146be00\n); return 0; }这段代码的核心逻辑就三点一是密钥超长先哈希二是ipad/opad异或操作复用同一个k_pad数组三是第二次哈希输入是“opad异或后的64字节 第一次的20字节摘要”。3.3 对照测试验证代码正确性上面main函数里的测试向量是RFC 2202的标准测试用例——密钥是20个0x0b消息是“Hi There”期望的HMAC值是b617318655057264e28bc0b6fb378c8ef146be00。如果你手边有OpenSSL可以用下面这条命令来交叉验证echo -n Hi There | openssl dgst -sha1 -hmac printf 0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b | xxd -r -p -hex输出结果应该也是b617318655057264e28bc0b6fb378c8ef146be00。这种“RFC标准向量 OpenSSL交叉验证”的组合是我在嵌入式开发里验证签名模块正确性的固定套路强烈建议你也这么干——不要只测试一个用例RFC 2202里有7个标准用例覆盖各种边界场景空密钥、长密钥、长消息等全过一遍才踏实。4. 实操中容易踩的坑4.1 输出大小写问题第三方平台的签名比对有的要求大写hex有的要求小写hex。我上面给的to_hex_string输出的是小写但很多平台要求大写。解决办法很简单把hex_digits[]改成0123456789ABCDEF就行。但有一个隐蔽的细节部分平台在比对时不区分大小写部分平台严格区分。对接之前一定先看清楚文档不然你会遇到“签名算得明明对但服务端一直报签名错误”的诡异情况。4.2 密钥编码问题C语言里处理字符串密钥时要特别注意密钥的字节表示。如果你的密钥本身就是ASCII字符串那直接用但如果平台给的密钥是hex字符串比如“0b0b0b0b”这种你就要先做hex解码再传给hmac_sha1。我见过很多人在这一步直接把hex字符串当ASCII密钥用了结果怎么算都不对。还有一个更常见的坑密钥末尾的换行符。如果密钥是从配置文件或环境变量里读的很可能带着一个\n这会让签名结果完全不同。4.3 消息的拼接顺序实际的接口签名里消息通常不是单一字符串而是按照ASCII码排序后的多个参数拼接。比如微信支付签名就是把所有参数按key的字典序排序然后拼成“key1value1key2value2”的格式最后再拼上密钥。这个拼接规则是平台定的不同平台细节略有差异——有的要URL编码有的不编码有的拼接前要去掉空值参数有的要求参数值类型按字符串处理。这些都是对接时最容易出错的地方而且错误往往不会在本地出现一上生产环境就暴露。5. 一个更完整的实战对接开放平台签名5.1 签名步骤拆解假设你要对接一个开放平台它规定了这样的签名流程把所有请求参数不含sign本身按key的ASCII从小到大排序拼成“k1v1k2v2”的形式然后用你的AppSecret作为HMAC-SHA1的密钥对拼接后的字符串做签名最后把签名转成大写hex放在请求的sign字段里。这个流程在C语言里实现起来大概是这样的int build_sign_request(const char *params[], const char *values[], int param_count, const char *secret, char *sign_out, size_t sign_out_size) { // 1. 对参数按key排序这里用简单的冒泡排序实际可用qsort // 2. 拼接成 k1v1k2v2 // 3. 调用 hmac_sha1(secret, strlen(secret), msg, strlen(msg), digest) // 4. to_hex_string 转大写 // 5. 放到 sign_out return 0; }这里我突然想强调一下冒泡排序这个“笨办法”。在实际项目中参数数量一般就几个到十几个排序算法的效率根本不是瓶颈反而是代码的可读性和零依赖性更重要。我曾经在嵌入式环境里用qsort时因为比较函数写错了导致排序结果不稳定排查了半天。后来干脆手写了简单的插入排序反而更可控。5.2 用CURL发送带签名的请求在Linux环境下一般用libcurl发送HTTP请求签名算好之后把它拼到请求URL或者POST表单里。#include curl/curl.h void send_request_with_sign(const char *url, const char *sign) { CURL *curl curl_easy_init(); if (!curl) return; char post_data[512]; snprintf(post_data, sizeof(post_data), param1value1param2value2sign%s, sign); curl_easy_setopt(curl, CURLOPT_URL, url); curl_easy_setopt(curl, CURLOPT_POSTFIELDS, post_data); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 5L); CURLcode res curl_easy_perform(curl); if (res ! CURLE_OK) { fprintf(stderr, curl failed: %s\n, curl_easy_strerror(res)); } curl_easy_cleanup(curl); }注意sign是已经转成hex字符串的。另外很多平台要求请求里带时间戳而且时间戳窗口只有几分钟你要是把时间戳写死了调试完再发请求就会报“签名过期”。这种问题遇到一次就长记性了。6. 调试工具与排查技巧6.1 快速定位问题的三种对照法我自己调试HMAC-SHA1签名时有一套三步对照法先算已知向量的HMAC值确认算法实现本身没问题用RFC 2202的测试向量。再用OpenSSL命令对同一个消息算一遍确认签名拼接逻辑没错注意OpenSSL命令行的-hmac参数后面直接跟密钥字符串但如果你要传的是hex密钥得先转成二进制文件。最后把要签名的原始字符串打印出来一字不差地对着平台文档检查。这套方法解决了我至少80%的签名问题剩下20%基本都是平台文档没写清楚的地方。6.2 常见问题速查表现象可能原因排查方向签名结果与平台不一致参数拼接顺序错误检查是否按ASCII排序签名结果不一致但排序正确密钥带了换行或空格打印密钥的每个字节本地调试通过线上失败时间戳过期检查时间同步与时区输出全是小写但平台要求大写hex输出大小写不匹配统一转成大写再比较密钥长度超过64字节先对密钥做SHA1的部分没实现对照RFC 2104检查消息内容含中文但编码不同UTF-8与GBK混用统一编码后再签名6.3 关于SHA1安全性的最后提醒我知道很多人会问SHA1已经被破解了为什么还在用HMAC-SHA1答案其实前面说过HMAC的密钥混合机制显著提升了攻击难度。即便攻击者能构造SHA1碰撞他也需要在不知道密钥的情况下把碰撞消息注入到你的HMAC计算中这在实践中几乎做不到。所以目前微信支付、支付宝等大平台的很多老接口仍然在用HMAC-SHA1作为签名算法不是他们落后而是这个组合在当前安全模型下是可靠的。不过如果是全新的项目我还是建议优先考虑HMAC-SHA256原理和代码结构完全一样只是把SHA1换成SHA256分组长度仍然是64字节摘要长度变成32字节。你只需要把我上面的代码里的state[5]改成state[8]把80轮的循环逻辑换成SHA256的版本其他框架不用动。我在实际项目里被这个算法折磨过好几次有两次都是因为密钥编码问题导致签名对不上。现在我的习惯是在实现完HMAC-SHA1之后第一件事不是对接平台而是先把RFC 2202的7个测试向量全跑一遍全部通过之后才开始做业务对接。这个习惯帮我省了无数排障时间也建议你养成。最后再分享一个小技巧把to_hex_string这个函数单独抽出来同时提供大小写两个版本对接不同平台时直接切换不要每次都在主逻辑里改字符表。本文还有配套的精品资源点击获取