☰
AK/SK签名认证实现指南:从签名原理到防重放与密钥轮转
2026/10/21 5:56:49 网站建设 项目流程

写这套东西的起因很简单:我们内部有一个开放接口平台,组里要求所有服务间调用都必须过签名认证。最开始有人提议直接放一个固定Token在Header里,省事。但Token一旦泄漏,别人拿着就能冒充调用方,连“这段请求是什么时候发的、带了哪些参数”都校验不了。后来我按云厂商通用的设计思路,实现了基于AK/SK的签名认证方案,跑通之后又踩了好几个坑,写出来给正准备做网关鉴权或者对接开放平台的老铁们一个完整参考。

这篇内容能帮你搞清楚三件事:AK和SK到底是什么关系、一次带签名的HTTP请求从构造到校验经历了什么、以及验签失败时到底应该从哪些环节去查。文章不求面面俱到,但求每一步的逻辑都讲透,你可以照着代码直接改。

1. AK/SK签名认证要解决的核心问题与整体设计思路

1.1 为什么云服务商普遍选择AK/SK而不是固定Token或用户名密码

很多第一次接触AK/SK的人会把它理解成“一对账号密码”,这个比喻不准确,但方向是对的。AK(Access Key ID)相当于一个公开的调用方ID,它随请求一起发送,用来让服务端知道“你是谁”。SK(Secret Access Key)是绝对不允许出现在网络传输里的,它只作为签名算法的密钥材料,在本地参与计算。

关键点在于:SK不会离开客户端,但服务端也能验证“你这个请求确实来自持有SK的人”。这就是对称签名的思路——服务端存着同一个SK,用相同的算法重新算一遍签名,比对结果来确定请求是否合法。固定Token的问题是它不绑定请求内容。比如你用一个Token调接口,抓包抓到之后,哪怕过了三天,攻击者依然可以拿着Token原样重放。而AK/SK签名会把请求里的关键参数、时间戳、随机数全部纳入签名计算,只要请求内容有一丁点变化,签名就对不上;时间戳超时会失效;同一个随机数重复使用也会被拒绝。所以表层是签名认证,底层其实是三件事:身份校验、数据防篡改、请求防重放。

1.2 签名的生成与校验:对称密钥在两端各自运算

整个流程可以用一个非常朴素的数学模型来概括:客户端和服务端共享同一个SK,客户端把“HTTP方法+规范化URI+规范化查询串+部分请求头+请求体哈希”拼接成一个待签名字符串,再用SK对它做HMAC-SHA256,得到签名值。服务端收到请求后,按同样的规则重新拼接这个字符串,再用自己存的SK算一次签名,两个签名相等就放行。

这里有个容易误解的地方:签名计算过程中没有“加密”一说。SK不是用来加密请求体的,请求内容该明文还是明文,SK只负责生成一段可以验证的签名。用一个生活化类比,AK/SK签名像你在快递单上盖一枚私人印章,别人看得见你的公司名(AK),但印章图案(签名)只有用你手里那枚真章才能盖出来,收件方用备案章去比对就知道真假。请求在传输过程被别人截获了也没用,因为任何修改都会让“印章图案”对不上号。

2. 规范化请求串的构造规则:签名误差的根源之地

2.1 Canonical Request的字段与顺序

签名认证真正麻烦的从来不是HMAC算法本身,而是“规范化请求串”(CanonicalRequest)的构造。因为客户端和服务端是两个完全独立的程序,它们对请求的理解必须完全一致,哪怕差一个空格、多一个斜杠,算出来的签名都不相同,服务端就会返回签名错误。我自己最早实现的时候,就是被这里各种细节折磨了好几轮。

一个典型的CanonicalRequest通常按如下顺序拼接:

  1. HTTP方法,转成大写,如 GET、POST。
  2. 换行符\n。
  3. CanonicalURI,即经过编码后的请求路径,注意路径里每个分段都要单独做URI编码,但路径中的/分隔符要保留。
  4. 换行符\n。
  5. CanonicalQueryString,即查询参数规范化后的字符串。
  6. 换行符\n。
  7. CanonicalHeaders,即参与签名的请求头列表,每一行格式为小写键名:值。
  8. 换行符\n。
  9. HashedPayload,即请求体的哈希值,通常用SHA256,转成十六进制小写字符串。

为什么要规定得这么细?因为签名本质上是把请求的关键特征“压”成一个固定长度的指纹。如果两端对特征的定义不一致,这个指纹就永远不会匹配。规范化的目的不是让请求变复杂,而是消除一切可能产生歧义的地方。

2.2 URI编码和Query排序的细节

Query参数的规范化是踩坑重灾区。比如GET /api/user?id=1&name=zhang,规范化之后不是简单拼接。规则一般是:

  • 把所有参数名按字典序升序排列。
  • 每个参数用key=value表示,参数名和值都要做百分号编码。
  • 多个参数之间用&连接。
  • 空格编码成%20,绝对不能编码成+。

为什么会这样?因为HTTP协议本身允许同一请求有多种不同写法。?a=1&b=2和?b=2&a=1在业务层可能是同一个请求,但字符串按原样拼接出来的签名串完全不同。如果服务端不要求排序,客户端必须先证明自己知道“正确的原始顺序”,而这个顺序在代理、网关转发过程中又很容易被改写。所以行业通行做法就是:不管客户端原始顺序是什么,统一按参数名的字典序排序后再拼。只要两端都用同一套排序规则,签名就能对齐。

路径也是一样。/api//user和/api/user在部分框架里会被当成同一个路由,但签名时不能含糊,必须严格按原始路径逐段编码。这也是很多验签失败的原因——某个网关中间层自动做了路径归一化,把多余的斜杠去掉了,但客户端签名时还是按原始路径算的,两边自然对不上。

2.3 请求体是否参与签名:安全性与效率的权衡

请求体要不要参与签名,这是一个值得单独想清楚的问题。参与的好处很明显:请求体内容被篡改时签名校验会失败,能防“中间人改数据”。坏处是:如果请求体很大,计算SHA256会消耗额外的时间和CPU;而且一旦中间层对请求体有任何操作(比如重新压缩、格式化成另一种JSON缩进),签名都会崩掉。

我给内部组件定的规则是:所有非GET请求的请求体必须参与签名,统一取原始字节流的SHA256后再拼接到CanonicalRequest。实现上有个细节:必须先读取完整的请求体字节流,算完哈希之后,再把相同的字节流放到请求里发出去。因为HTTP请求体是流式的,一旦读出来就不能原位放回,很多人第一次封装时都容易在这里翻车。我当时是先把请求体用bytes对象缓存下来,算完哈希后再作为data传入HTTP客户端,这样两端处理的就是完全相同的一份内容。

3. 客户端签名与服务端验签的代码级实现

3.1 客户端签名组件的实现

我直接用Python写了一套完整的签名组件,思路很清楚,大家可以照着改造到其他语言。先看签名计算的核心函数:

import base64 import hashlib import hmac import time import uuid from urllib.parse import quote, urlencode def sign(secret_key: str, string_to_sign: str) -> str: digest = hmac.new( secret_key.encode("utf-8"), string_to_sign.encode("utf-8"), hashlib.sha256 ).digest() return base64.b64encode(digest).decode("utf-8")

HMAC非常简单,SK作为密钥,待签字符串作为消息,用SHA256算完再做Base64。接下来是构造CanonicalRequest的完整逻辑:

def build_canonical_request(method: str, path: str, query: dict, headers: dict, body: bytes) -> str: method = method.upper() # 路径逐段编码,保留斜杠 canonical_uri = "/".join([quote(seg, safe="") for seg in path.split("/")]) if not canonical_uri.startswith("/"): canonical_uri = "/" + canonical_uri # 查询参数按 key 字典序排序 sorted_query = sorted(query.items(), key=lambda item: item[0]) canonical_query = "&".join( [f"{quote(k, safe='')}={quote(v, safe='')}" for k, v in sorted_query] ) # 参与签名的请求头,统一转小写 header_names = ["host", "x-timestamp", "x-nonce"] canonical_headers = "".join( [f"{name}:{str(headers[name]).strip()}\n" for name in header_names if name in headers] ) payload_hash = hashlib.sha256(body).hexdigest() return f"{method}\n{canonical_uri}\n{canonical_query}\n{canonical_headers}\n{payload_hash}"

这里面几个细节值得逐一说明。第一,路径编码时我先按/把路径切分成段,再把每一段单独做百分号编码。因为如果对整个路径统一编码,斜杠会被转成%2F,服务端做同样操作时语义就乱了。第二,参与签名的请求头固定为host、x-timestamp、x-nonce三个,其中host是HTTP标准头,表示目标主机和端口,x-timestamp是UTC毫秒时间戳,x-nonce是请求唯一随机串。第三,请求头的值在签名前要把首尾空格去掉,否则加上一个空格两个签名就不一致。

客户端组装完整请求的代码如下:

def build_signed_request(ak: str, sk: str, method: str, url: str, query: dict = None, body: bytes = b"", extra_headers: dict = None): parts = urlparse(url) path = parts.path if not path: path = "/" timestamp = str(int(time.time() * 1000)) nonce = uuid.uuid4().hex headers = { "host": parts.netloc, "x-timestamp": timestamp, "x-nonce": nonce, "content-type": "application/json", } if extra_headers: headers.update(extra_headers) canonical_request = build_canonical_request( method, path, query or {}, headers, body ) string_to_sign = f"{canonical_request}" signature = sign(sk, string_to_sign) headers["Authorization"] = f"AKSK {ak}:{signature}" return url, headers, body

我简化掉了对query和body同时存在的复杂场景,但整体逻辑已经能覆盖绝大多数接口调用。注意Authorization头的格式是AKSK AK值:签名值,中间用空格分隔,服务端解析时先按空格拆成两段,再按冒号拆出AK和签名。

3.2 服务端验签:四道检查缺一不可

服务端验签不能只比对签名值,最少要做四道检查。我封装了一个统一的鉴权入口,函数签名为verify_request(ak, sk, method, path, query, headers, body),流程如下:

def verify_request(ak: str, sk: str, method: str, path: str, query: dict, headers: dict, body: bytes) -> bool: # 1. 从 Authorization 头解析 AK 和签名 auth_parts = headers.get("authorization", "").split(" ", 1) if len(auth_parts) != 2 or auth_parts[0] != "AKSK": return False ak_in_header, signature = auth_parts[1].rsplit(":", 1) if ak_in_header != ak: return False # 2. 检查时间窗口 timestamp = int(headers.get("x-timestamp", "0")) now = int(time.time() * 1000) if abs(now - timestamp) > 300_000: return False # 3. 检查 nonce 是否已使用 if not nonce_storage.try_add(headers.get("x-nonce"), 300): return False # 4. 重建 CanonicalRequest 并比对签名 canonical_request = build_canonical_request(method, path, query, headers, body) expected_signature = sign(sk, canonical_request) return hmac.compare_digest(signature, expected_signature)

第一道检查解析AK,查这个AK在系统里对应的SK。第二道检查时间差,超过5分钟直接拒绝。第三道检查用nonce防重放,同一个nonce在窗口内只能使用一次。第四道检查重建CanonicalRequest并比较签名,这里必须使用常量时间比较函数,不能用普通的字符串相等比较,因为传统的==比较在遇到第一个不匹配字符时就会返回,攻击者可以通过计时推测出正确的签名,虽然实际利用难度不低,但这是安全编码的基本素养。

3.3 多语言签名的普遍差异

如果客户端和服务端用的是不同语言,签名不一致的问题几乎必然出现。最常见的差异有三个:

  1. URI编码结果不同。不同语言的quote函数默认是否保留某些字符不一样,比如Python的urllib.parse.quote默认不编码/,Go的url.QueryEscape会把空格编码成+。解决办法是建立一份统一的编码规范,并针对每种语言封装测试用例。
  2. JSON序列化器输出不同。服务端框架解析请求体的时候,如果先解析成对象再原样输出,字段顺序和空格可能和客户端发来的原始字节不一致。这会导致请求体哈希不匹配。最稳妥的做法:签名中的请求体哈希基于原始字节流,服务端在网关层直接拿原始字节算哈希,不要先做JSON反序列化。
  3. 请求头名称的大小写。HTTP头本身不区分大小写,但签名规则要求统一转成小写参与计算。有的框架会默认把Header名转成X-Timestamp这样的驼峰形式,参与拼接时如果不处理就gg。

4. 防重放攻击的完整设计:时间戳、Nonce与缓存回收

4.1 时间窗口的选择与时钟偏差

签名认证天然要对时间敏感。因为一个合法请求被截获后,如果服务端不限制时间范围,攻击者可以把它存下来,隔一天甚至一年再发出去。要防这种重放,最简单的手段就是给请求一个“保质期”。

5分钟是很多云厂商采用的默认窗口,这个值不是拍脑袋定的,而是综合了几方面因素。窗口太短,比如30秒,客户端服务端只要有任何一方时钟有偏差,合法请求都可能被判过期;窗口太长,比如1小时,则重放攻击的利用窗口也变长。5分钟对大多数线上系统是够用的,配合NTP时钟同步,两端通常能保持在秒级偏差内。

实现时还有一个容易忽略的细节:比较时间戳时要用绝对值,即abs(now - timestamp) <= 300000。因为客户端时钟可能比服务端快,也可能比服务端慢。只做now - timestamp < 300000这种单向比较,就会出现“未来请求”永远合法的情况,攻击者完全可以伪造一个未来时间戳来绕过过期校验。

4.2 Nonce的存储方案与Redis实现

光有时间戳还不够。如果攻击者在5分钟窗口内拿到了一个合法请求,原样重放一次,时间戳依然在窗口内,签名也完全一致,服务端会把它当成正常请求。所以还要引入nonce——一个由客户端生成的随机串,每次请求都不同。

服务端的规则是:同一个nonce在有效期内只能被接受一次。这个“只接受一次”必须依赖共享存储。单机部署可以直接用内存里的ConcurrentHashMap加过期淘汰,多节点部署就必须用Redis一类的外部存储。我用的Redis方案很简单:

SET nonce:{timestamp}:{nonce} 1 EX 300 NX

这条命令的意思是:只有当这个key不存在时才设置成功,且300秒后自动过期。如果设置失败,说明这个nonce已经出现过了,直接拒绝请求。用Redis的原子性,天然避免了并发场景下两个相同请求同时通过校验的竞态问题。

存储量方面其实不用太担心。每秒钟如果峰值有1万请求,5分钟内最多产生300万个key,每个key大概几十字节,完全在Redis可接受范围内。比较麻烦的是清理策略——好在有EX过期时间兜底,不需要额外写定时任务。

这里还要提醒一点:nonce的随机性必须足够好。我见过有团队图省事,用时间戳加自增数字当nonce,攻击者一旦猜出规律,就能推断出后续nonce并提前准备重放攻击。建议直接用UUID,够用且不需要自己管理随机源。

4.3 签名比较与错误信息的安全隐患

防重放是一个需要整体考虑的事情,但很多人只在签名计算上花功夫,忽略了一些边角问题。比如服务端验签失败时返回的错误信息,我曾经在开发环境把Authorization头原样打到日志里,签名值也一并打印了。这个签名值虽然不能反推出SK,但攻击者看到了真实的签名样例,可以拿来做离线字典攻击,一旦SK字典比较脆弱,就有被猜出的可能。生产环境一定要把签名值和SK打码,日志只保留AK前缀和请求ID。

还有签名比较本身。前面说要用hmac.compare_digest,我再展开讲讲原因。普通的字符串比较在Java和Python里都是逐字符比较,遇到第一个不同的字符就停止。理论上攻击者可以通过大量请求精确测量出“我的伪造签名和服务端真正在计算的签名在哪个位置开始不一样”,结合计时信息逐步逼近正确签名。compare_digest的耗时与字符串内容无关,每次都比较完整个字符串,从时间侧彻底切断了信息泄漏。虽然对大部分内部的系统来说风险不高,但你既然在写鉴权组件,从一开始就用安全的比较方式,成本几乎为零,没有理由不这么做。

5. 一次真实线上验签失败的排查全过程

5.1 现象:本地通过、线上偶发

理论讲再多,最后还是要回到实战。我把一段真实发生过的排查过程完全复盘出来。当时我们有一个服务A调用服务B,A用我封装好的客户端发请求,本地联调一切正常,但上了测试环境之后,B的服务端日志频繁出现签名不匹配的报错。诡异的是,报错不是100%触发,有些请求能过,有些请求过不了,重试一次往往又成功了。

这个现象本身就很有信息量。如果是AK查不到对应SK,那应该是所有请求都失败;如果是SK配置错误,那应该是稳定地100%失败。偶发性失败说明两端签名的规则基本一致,只是某些特定条件下会不一致,大概率是请求内容在传输过程中被某个环节改写了。

5.2 定位过程:从Authorization头到中间件

我第一步是让B服务在验签失败时返回一个内部错误码,其中包含“服务端计算出的签名值”,再让A把“客户端计算出的签名值”打出来。两边一对比,两个Base64字符串长度一致但内容不同,说明不是算法差异,而是输入不同。

随后我把焦点放在CanonicalRequest的各个组成元素上。先检查时间戳和nonce,发现这两个值完全一致,排除。接着对比请求体哈希,一致,排除。最后对比CanonicalQueryString,果然发现了问题。

我们的调用URL形如GET /api/order?id=123&type=normal。A服务发请求时,query参数是字典里原始写入的顺序,客户端签名也是按这个顺序拼接的。但请求经过B前面加的一层API网关时,网关对URL做了参数解析和重排序,把type=normal排到了id=123前面,还自动给值做了编码。服务端收到请求时取到的Query参数顺序,和客户端签名时用的顺序已经不一样了。

5.3 根因与修复方案

根因找到了:客户端签名用的Query顺序是字典序,但参数值中存在大小写差异时,字典序又依赖具体字符编码。而API网关重写URL时重新拼接了参数,导致最终顺序和客户端签名时构造的顺序不一致。为什么本地联调没暴露?因为本地直接连B服务,没有经过网关,自然不存在参数重写。

修复方案其实有两步。第一步,把参与签名的Query参数范围限定为“已经规范化后的参数”,即客户端在构造签名的同时,也把最终要发送的URL同步改成规范化后的顺序。第二步,在API网关上关闭不必要的参数重写,或者在网关层增加“透传原始Query串”的开关。两者选一个就能解决,但我建议两个都做,因为网关改动有回归风险,客户端先行调整能形成双保险。

这个案例给我们的教训是:签名认证的边界条件往往不在两端服务本身,而在它们之间那些“看不见”的中间层。排查签名不匹配问题,永远要首先确认请求的完整链路里有没有代理、网关、负载均衡器对请求做过改写。

6. AK/SK密钥管理与轮转的工程化实践

6.1 SK的安全存储:从环境变量到密钥管理服务

签名算法本身写得再正确,如果SK存泄漏了,一切都是白搭。SK的存储有几个层级,我按安全性从低到高排一下:

  • 硬编码在代码里:最不推荐。代码仓库一旦泄漏,所有SK一次性全暴露。
  • 环境变量:适合中小项目和本地开发,至少不会进Git历史。
  • 配置文件加访问控制:权限到位可用,但要防止日志和监控系统误采集。
  • 密钥管理服务(KMS/密钥管理系统):生成、存储、轮转都由独立服务管理,应用运行时只拿内存中的副本。这是生产级标准做法,成本更高但安全收益最大。

我目前的实践是:SK用环境变量注入,同时敏感字段在配置中心做加密存储,应用启动时解密加载。日志打印时统一拦截,任何包含SK的字段一律替换成***。这套方案对绝大多数团队已经够用。

6.2 双AK轮转的平滑迁移方案

密钥不能永久不变。定期轮转SK是安全审计里的常规要求,但轮转最怕的是服务端刚换掉SK,客户端还没来得及更新,瞬间全线验签失败。解决这个问题的标准方案是双AK策略。

具体做法是:每个调用方在系统里同时维护两个AK/SK对,分别为“当前生效”和“下一个待生效”。服务端校验时,先从Authorization头解析出AK,再用这个AK对应的SK去算签名,而不是统一用某个全局SK。这样哪怕客户端还在用旧SK,只要服务端没有删除旧AK,验证依然能通过。迁移流程是:

  1. 在控制台为调用方生成新的SK,标记为“启用中”,旧SK标记为“停用中但仍可验证”。
  2. 客户端更新配置,开始用新SK签名。
  3. 一段时间后,确认无旧SK请求流量,再删除旧AK对。

看起来多一步,但整个切换过程对调用方完全无感。密钥轮转最忌讳的就是一刀切,尤其当你的平台上有几十上百个外部调用方时,单点式的强制变更一定会带来生产事故。

6.3 AK的权限范围与审计日志

另外一个容易被忽视的问题:给调用方签发AK时,不要所有人都给“超级密钥”,必须划分最小权限。比如一个只读报表接口的调用方,不应该有调用删除接口的权限。我在签发AK时会在数据库里记录对应的API权限列表,服务端签名校验通过后,再查一次AK是否有目标接口的调用权限。校验顺序一定要先验签再鉴权,因为如果是无效签名,应该优先返回401,而不是让攻击者通过接口顺序试探出权限列表。

审计日志也不能只是自己看。每条关键接口调用都记录AK指纹、请求时间、请求路径、结果状态码、请求ID。时间窗口内的nonce本身在Redis里会过期,但如果审计需要,建议把nonce连同请求ID持久化到日志系统,这样排查时就多一条线索。

最后分享一个很多人会忽略的小技巧:你可以把AK设计成带版本号的格式,比如v2_开头,这样解析AK时直接就能判断它对应的是哪一代签名算法,未来升级算法时不用大改框架。我在轮转SK时顺手加了这么一个前缀规则,后来迭代签名版本时确实少折腾了很多。AK/SK签名认证这件事,看起来简单,只有真正在线上跑过,踩过URI编码、网关重写、时钟漂移这些坑,才会明白规范化和防御性设计的分量。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询