调用云服务的开放接口时,绝大多数平台都会要求对请求进行签名,用以证明请求确实来自持有密钥的用户,并且在传输过程中没有被篡改。签名机制看似复杂,本质上就是用密钥对请求的关键信息做一次带密钥的哈希运算,服务端用同样的方式重算一遍,比对结果是否一致。本文以Ruby为例,从最基础的HMAC-SHA256讲起,再深入到V4签名标准的完整实现。

HMAC-SHA256的基本原理与Ruby实现
HMAC的中文名叫散列消息认证码,它在普通哈希函数的基础上引入了一个密钥。普通的SHA256只需要消息本身就能算出摘要,任何人都可以伪造;而HMAC-SHA256需要同时掌握消息和密钥才能算出正确结果,攻击者即使截获了消息和签名值,由于不知道密钥,也无法伪造出新的合法请求。正是这个特性让它成为API签名领域的标配算法。
HMAC的内部结构并不复杂:设密钥为K,消息为M,最终结果为 H((K' xor opad) || H((K' xor ipad) || M)),其中K'是密钥经过填充或哈希处理后的定长密钥,ipad和opad是两个固定的填充常量。理解这个双重哈希结构有助于明白为什么密钥长度不同时结果也不同,也解释了为什么签名对密钥的保密性要求如此之高。
Ruby的标准库中已经内置了openssl和digest,无需引入第三方依赖就能完成HMAC-SHA256计算。下面是最常见的几种写法:
require 'openssl'
require 'digest'
require 'base64'
secret_key = 'your-secret-key'
message = 'GET\n/\nhost=api.ipipp.com'
# 方式一:直接得到二进制摘要
raw_digest = OpenSSL::HMAC.digest('SHA256', secret_key, message)
# 方式二:十六进制字符串,很多平台直接使用这种格式
hex_digest = OpenSSL::HMAC.hexdigest('SHA256', secret_key, message)
# 方式三:Base64编码,部分平台要求Base64形式的签名
b64_digest = Base64.strict_encode64(raw_digest)
puts hex_digest
puts b64_digest需要特别注意的是输出格式问题。同样是HMAC-SHA256,不同平台要求的输出形式差别很大:有的要十六进制小写字符串,有的要Base64,有的要原始字节再做URL编码。如果发现签名怎么算都不对,优先检查输出编码是否与官方文档一致。另外,Base64.encode64会在结果中插入换行符,做签名时通常应该使用Base64.strict_encode64来避免这个问题。
V4签名标准的完整流程拆解
以主流云厂商普遍采用的V4签名标准为例,签名过程可以分解为四个步骤:构造规范化请求、构建待签字符串、计算派生签名密钥、生成最终签名。整个流程的设计意图是让客户端和服务端对请求的理解完全一致,任何一点差异都会导致签名不匹配。
第一步是构造规范化请求,格式为:HTTP方法、规范化URI、规范化查询字符串、规范化请求头、参与签名的请求头列表、请求体的哈希值。其中规范化查询字符串要求把所有参数名和参数值按URL编码后,按参数名的字典序排序,再用&符号连接。请求体的哈希则是对请求体内容做一次SHA256运算并取十六进制小写。Ruby实现如下:
require 'digest'
def uri_encode(str)
# RFC3986规范:仅保留字母数字和 - . _ ~ 不编码
str.gsub(/[^A-Za-z0-9\-._~]/) { |c| '%%%02X' % c.ord }
end
def canonical_query_string(params)
params.sort.map do |key, value|
"#{uri_encode(key.to_s)}=#{uri_encode(value.to_s)}"
end.join('&')
end
http_method = 'GET'
canonical_uri = '/'
query_string = canonical_query_string('Action' => 'DescribeInstances', 'Limit' => '10')
host = 'api.ipipp.com'
signed_headers = 'host;x-date'
x_date = '20240515T081500Z'
payload_hash = Digest::SHA256.hexdigest('')
canonical_request = [
http_method,
canonical_uri,
query_string,
"host:#{host}\nx-date:#{x_date}\n",
signed_headers,
payload_hash
].join("\n")第二步是构建待签字符串,它由算法名称、时间戳、凭证范围和规范化请求的哈希值按换行符拼接而成。凭证范围的格式为日期、地域、服务名和固定后缀,用斜杠分隔,例如20240515/cn-north-1/ecs/v4_request。这个字符串就是后续HMAC运算的消息部分。
第三步是派生签名密钥,这是V4签名中最容易出错的地方。签名密钥并不是直接使用访问密钥,而是通过多轮HMAC运算层层派生出来的:先用日期对密钥做一次HMAC,再用地域对结果做HMAC,接着用服务名,最后用固定字符串。每一步的输入都是上一步的二进制输出,而不是十六进制字符串,这一点用Ruby的OpenSSL::HMAC.digest天然满足,但如果用其他语言或自行拼接字符串就会出问题。
require 'openssl'
def derive_signing_key(secret_key, date, region, service)
k_date = OpenSSL::HMAC.digest('SHA256', secret_key, date) # 例如 20240515
k_region = OpenSSL::HMAC.digest('SHA256', k_date, region)
k_service = OpenSSL::HMAC.digest('SHA256', k_region, service)
OpenSSL::HMAC.digest('SHA256', k_service, 'v4_request')
end
def sign_request(canonical_request, signing_key, string_to_sign)
# string_to_sign 形如: 算法名\n时间戳\n凭证范围\n规范化请求的SHA256哈希
OpenSSL::HMAC.hexdigest('SHA256', signing_key, string_to_sign)
end第四步是把签名组装进请求。常见做法是构建一个Authorization请求头,其中包含凭证范围、参与签名的请求头列表和签名值;对于预签名URL的场景,则是把签名作为查询参数附加到URL末尾并设置过期时间。两种方式服务端校验逻辑一致,只是签名信息的传递位置不同。
签名失败的常见原因与排查方法
签名调试最让人头疼的地方在于,服务端只会告诉你签名不匹配,不会告诉你错在哪一步。根据经验,大部分签名错误可以归结为几类。第一类是时间戳问题:V4签名通常对请求时间有严格限制,一般允许十五分钟以内的偏差,本机时钟不准就会导致签名过期被拒绝。排查方法是把本机时间与NTP服务器同步,或在调试阶段先打印出实际发送的时间戳检查是否合理。
第二类是编码不一致。URL编码的规范存在多种变体:空格应该编码成%20还是+,星号、波浪线等字符是否需要编码,不同语言的默认行为并不相同。如果客户端和服务端对同一个参数的编码结果不同,规范化查询字符串就不一样,签名自然对不上。建议在实现时严格按照平台的官方规范自行编写编码函数,而不是依赖HTTP库的默认行为。
第三类是参与签名的请求头与实际发送的请求头不一致。比如签名时声明对host和x-date两个头做签名,但实际请求经过代理后header名被改成了小写或被中间件删除,服务端拿到的头信息与签名声明不符。另外,请求体的哈希也容易出错:GET请求通常空体的哈希是固定值,而POST请求必须对实际发送的字节做哈希,如果实际发送时被框架重新编码,哈希就对不上了。
require 'net/http'
require 'uri'
def send_signed_request(url, headers)
uri = URI(url)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = (uri.scheme == 'https')
request = Net::HTTP::Get.new(uri.request_uri)
headers.each { |k, v| request[k] = v }
response = http.request(request)
puts "状态码: #{response.code}"
puts "响应内容: #{response.body[0, 200]}"
response
end排查签名问题时,一个实用的技巧是把整个签名流程的中间产物全部打印出来:规范化请求原文、待签字符串、派生密钥的十六进制形式、最终签名值,然后逐项与官方SDK在相同输入下的输出对比。只要中间某一环出现差异,就能立刻定位问题所在。此外,很多云平台的文档会给出一个固定的测试密钥和期望输出,用它来验证自己的实现是否正确,比直接拿线上密钥调试效率高得多。
总结来说,API签名并没有想象中神秘,核心就是规范化加HMAC运算。用Ruby实现时,标准库已经足够覆盖全部需求,真正的工作量在于严格按照平台的规范处理编码、排序、时间格式等细节。把规范化请求的每一步拆开验证,签名问题基本都能在短时间内解决。
API签名HMAC-SHA256Ruby修改时间:2026-09-08 10:26:24