简介: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字节密钥,分别与ipad(0x36重复64次)和opad(0x5c重复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的字典序排序,然后拼成“key1=value1&key2=value2”的格式,最后再拼上密钥。这个拼接规则是平台定的,不同平台细节略有差异——有的要URL编码,有的不编码;有的拼接前要去掉空值参数,有的要求参数值类型按字符串处理。这些都是对接时最容易出错的地方,而且错误往往不会在本地出现,一上生产环境就暴露。
5. 一个更完整的实战:对接开放平台签名
5.1 签名步骤拆解
假设你要对接一个开放平台,它规定了这样的签名流程:把所有请求参数(不含sign本身)按key的ASCII从小到大排序,拼成“k1=v1&k2=v2”的形式,然后用你的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. 拼接成 k1=v1&k2=v2 // 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), "param1=value1¶m2=value2&sign=%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这个函数单独抽出来,同时提供大小写两个版本,对接不同平台时直接切换,不要每次都在主逻辑里改字符表。
本文还有配套的精品资源,点击获取