AK/SK 签名机制、临时凭证与密钥管理安全实践
1. AK/SK 到底是什么从一次线上事故说起AK/SK 这四个字母最近在搜索框里的出现频率有点反常。点进去看结果一半是正经的云服务开发文档一半是些八竿子打不着的东西。搜出来的结果越杂越说明一件事很多人其实没搞清楚它到底是干什么的只是遇到过、听说过或者在某份配置里抄过。我在做后端和运维的这些年里被问得最多的问题之一就是——AK/SK 和账号密码到底差在哪为什么调个接口非要搞这么一套东西。先说结论AK 是 Access Key ID访问密钥标识SK 是 Secret Access Key访问密钥密文。这两个是一对成对出现、配合使用。AK 相当于你的用户名 门牌号是公开的、可以出现在请求里的那部分SK 相当于只有你和对方知道的暗语它永远不会出现在网络上只参与本地计算。任何一个云平台、开放 API 平台、对象存储服务都会给你生成这么一对密钥用来证明这次请求确实是你发的。我第一次真正搞明白这套东西是因为一次线上事故。当时团队把一段采集脚本部署到一台临时机器上脚本里硬编码了 AK/SK。结果那台机器被回收、镜像被打包、日志被上传SK 就这样一路漂出去了。两周后账单突然暴涨追查才发现有人在用我们的密钥跑批量任务。那次的教训很直接AK 泄露的可怕之处在于它不是账号被盗而是你的账号被别人合法地用了——所有请求看起来都是你发的签名完全正确风控也拦不住。这套机制能解决的问题其实很明确。它要在一个完全不可信的网络里让服务端确认三件事请求是谁发的、内容有没有被改过、这个请求是不是刚刚才发出来的。注意这里没有加密这个目标——AK/SK 签名不做内容加密它只做身份认证和完整性校验。很多人混淆了这一点以为用了 SK 签名请求体就安全了这是错的明文照样是明文。适合看这篇内容的人我大致分三类第一类是刚接触云服务、要调别人接口的开发者你需要在代码里正确使用密钥第二类是负责部署和运维的同学你要决定密钥存在哪、怎么轮换、怎么防止它跟着镜像流出去第三类是做安全评审或者架构设计的你要判断一套鉴权方案到底靠不靠谱。这三类的关注点完全不同所以下面我会把原理、实操、管理、排查分开讲你按需取用。1.1 长密钥与短凭证的分工理解 AK/SK最关键的一点是分清长期凭证和临时凭证。AK/SK 属于长期凭证它没有过期时间只要你没手动删它就一直有效。这就带来一个天然矛盾越方便的东西越危险。你把它写进脚本、塞进配置文件、打进 Docker 镜像它就能一直用下去直到某天被人捞走。临时凭证则是另一套思路。服务端先给你一个短期的、有明确过期时间的令牌通常有效期从 15 分钟到 12 小时不等你拿着它去调接口。令牌过期了就得重新申请申请的过程本身需要一次身份验证。这样即使令牌泄露攻击窗口也是有限的。那为什么还要保留长期 AK/SK因为它是信任链的起点。临时凭证不能凭空产生必须有人签发签发者自己得先有一个长期身份。就像公司门禁员工用的是有效期一天的临时卡但制卡机需要一把长期的主密钥。你把主密钥随身带着天天刷卡显然不合理但你把主密钥弄丢了那就是整个门禁系统失守。所以实际使用中的正确姿势是长期 AK/SK 只用于换取临时凭证业务代码里跑的全是临时凭证。我在做架构评审时看到业务代码里直接硬编码长期 SK基本会直接打回去。这条规则的收益极高成本极低——多写十几行换取凭证的代码就能把风险窗口从永久压缩到几小时。1.2 为什么不用账号密码直接调接口这个问题我被问过太多次值得单独说清楚。用账号密码调接口听起来更直观实际上有几个绕不开的坑。第一个坑是密码会裸奔。账号密码认证通常是把用户名和密码直接放进请求体或者 Basic Auth 头里发出去。虽然走的是 HTTPS但在服务端的访问日志、网关的转发日志、代理的中间层里密码是明文的。一旦某一层出了问题密码就暴露了。而 SK 从不传输只参与本地 HMAC 计算服务端也不需要知道你的 SK 原文——它只需要用自己保存的副本算一遍比对签名是否一致。一个传输一个不传输这是本质区别。第二个坑是权限粒度太粗。账号密码代表的是整个人你能干的事它全能干。但实际业务需要的是细粒度授权这个脚本只能读某个目录下的文件不能删这个服务只能发短信不能改配置。AK/SK 天生支持给每个密钥绑定独立的权限策略一把钥匙开一把锁。账号密码做不到这一点。第三个坑是无法安全轮换。改密码是全局操作改了之后所有用到的地方都得同步更新包括你自己不知道的那些角落。而 AK/SK 可以按密钥维度独立轮换新密钥上线、旧密钥下线互不影响。1.3 AK/SK 和 Token、OAuth 的边界在哪经常有人把这几样东西混着用结果方案越做越乱。简单划一下边界机制凭证形态是否传输典型有效期主要用途AK/SK密钥对SK 不传输SK 仅本地参与签名长期需手动轮换服务端之间调用、基础设施鉴权Token单个字符串全程传输放请求头短期几小时到几天用户会话、前端调用后端OAuth授权码换 Token传输短期 刷新机制第三方代表用户访问资源核心区别在于**签名还是持有**。AK/SK 是签名型凭证本身不出现出现的是它的运算结果。Token 是持有型谁拿到字符串谁就是合法用户。签名型天然抗窃听但不抗重放所以才需要时间戳持有型抗重放能力依赖有效期但一旦被截获就当场失守。理解了这个区别很多设计决策就顺了。比如服务端内部服务互相调用用 AK/SK 签名更合适因为调用方是可信的代码不需要用户授权流程而面向用户的场景用 Token 更合适因为你不能让每个前端用户都存一个 SK——那等于把长期密钥发给不可信设备。2. 签名机制拆解为什么改一个字就报 403很多人第一次调签名接口最常见的失败是 403 或者签名不匹配然后盯着代码看半天找不到问题。原因往往不是代码写错了而是没搞懂签名到底在签什么。这一节我把签名的组成要素和计算过程拆开讲理解了之后排查这类问题会快很多。2.1 一次请求签名的组成要素签名不是签请求体而是签一份请求的摘要描述。这份描述通常由几部分组成HTTP 方法GET、POST、PUT、DELETE方法不同待签名串不同。规范化后的资源路径注意是规范化后的比如路径里的重复斜杠要去掉特殊字符要按规则编码。规范化后的查询字符串参数要按字典序排序参数值要做 URL 编码空格要编码成%20而不是。参与签名的请求头通常包括 Host、Content-Type、时间戳头、以及一个哈希后的请求体摘要头。请求体的哈希值对请求体做一次 SHA256把十六进制结果放进签名头里。这样请求体被改了哈希就对不上。这五部分拼成一个多行字符串叫规范请求串Canonical Request。这一步是整个签名机制里最容易出错的地方因为规则非常死板少一个换行、多一个斜杠、大小写不对、编码方式不一致结果就完全不同。服务端和你用的是同一套规则你得保证两边算出来一模一样。注意规范化规则里最坑的是 URL 编码。很多语言自带的编码函数会把空格编成把~编成%7E而签名规范通常要求空格编成%20、~保持原样不编码。这两处不一致签名必错而且错误信息往往只说签名不匹配不给具体位置。2.2 HMAC-SHA256 的计算过程拿到规范请求串之后流程分两步走。第一步对规范请求串做一次 SHA256得到十六进制摘要然后把算法标识 时间戳 凭证范围 这个摘要拼成新的字符串叫待签名字符串String to Sign。第二步用 SK 派生出一个签名密钥再对待签名字符串做 HMAC-SHA256最后 Base64 编码或者转十六进制得到最终签名值。为什么不是直接用 SK 算 HMAC而要派生一层因为直接拿长期密钥参与每次签名理论上给了攻击者更多样本去分析。派生过程是一条 HMAC 链import hmac import hashlib def hmac_sha256(key: bytes, msg: str) - bytes: return hmac.new(key, msg.encode(utf-8), hashlib.sha256).digest() def derive_signing_key(secret_key: str, date: str, region: str, service: str, prefix: str SK) - bytes: # 各厂商的前缀不同有的用固定字符串有的用空串这一点必须查文档 k_date hmac_sha256((prefix secret_key).encode(utf-8), date) k_region hmac_sha256(k_date, region) k_service hmac_sha256(k_region, service) k_signing hmac_sha256(k_service, request) return k_signing代码不复杂但有个细节值得说派生是单向的。给你派生后的签名密钥你推不回 SK给你签名值你也推不回签名密钥。这就是为什么 SK 可以一直不传输服务端却能验证你——它手里有 SK 原文自己算一遍就行。还有个实用好处派生密钥是按日期、地域、服务三维度区分的。这意味着同一天、同一个地域、同一个服务的签名密钥是固定的可以缓存起来复用不用每次都从 SK 重算。在高并发场景下这个缓存能省下可观的 CPU。2.3 时间戳与防重放设计签名能证明是你发的但证明不了是现在发的。攻击者如果完整截获了一次请求包括签名头他可以直接把整个请求重发一遍服务端验证照样通过。这就是重放攻击。防御手段是时间戳。签名里必须包含一个请求时间服务端收到后跟自己的时间比对如果偏差超过允许窗口常见是 15 分钟直接拒绝。这样截获的请求只有很短的可用窗口而且这个窗口内的重复请求还能被进一步拦截。时间戳带来的问题也很多我踩过的坑至少有这几个机器时间不同步。容器里的时间如果跟宿主机差了几分钟而宿主机又没跟时间服务器同步签名就会因为时间偏差过大被拒。这个错误的表象是 403很容易被误判成签名算错了。时区搞混。签名规范里的时间戳通常是 UTC 时间格式像20240521T083000Z。如果你的代码用的是本地时间差了 8 小时必然失败。服务端也有时钟漂移。你自己机器准不代表对方准。遇到过对方网关时间慢了几分钟导致我们正常的请求被当成未来时间拒绝。这种问题只能靠对齐时间源解决。所以排查签名问题时第一件事是核对时间而不是去看签名代码。这个顺序改过来能省掉大量无效排查。3. 从零手写一个签名调用完整实操流程光看原理容易发虚这一节我带你走一遍完整流程从创建密钥到成功调用每一步都说清楚在干什么。为了让内容通用我用一套标准的签名规范来演示实际使用时把前缀、服务名、地域名替换成你所用平台的对应值即可。3.1 创建子账号与密钥第一步不是写代码是配置权限。直接在主账号下建密钥等于给自己埋雷。正确做法是创建一个子账号有的平台叫 RAM 用户、IAM 用户这个子账号不绑定任何登录密码只用于程序调用。给子账号绑定最小权限策略。比如你的脚本只需要往某个存储桶上传文件那就只给这一个桶的写权限别图省事给全量权限。在子账号下创建 AK/SK。创建时 SK 只会显示一次页面关掉就再也看不到了必须当场保存到密码管理工具里。记录 AK 和 SK同时记下创建时间和用途备注。这一步看着多余等你要轮换的时候就知道有多重要了。我见过太多团队密钥是五年前某个已经离职的同事建的没人知道它被哪些服务用着也没人敢删。所以从第一把密钥开始就要有台账用途、负责人、创建时间、上次轮换时间这四项一个都不能少。3.2 构造待签名字符串假设我们要调一个接口方法 POST路径/api/v1/files/upload查询参数bucketdemo、prefixlogs/2024请求体是一段 JSON。规范化之后各部分长这样POST /api/v1/files/upload bucketdemoprefixlogs%2F2024 content-type:application/json host:api.example.com x-sign-date:20240521T083000Z x-sign-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 content-type;host;x-sign-date;x-sign-content-sha256 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855这里有几个必须盯住的点第二行路径以斜杠开头结尾没有斜杠除非本来就是根路径。第三行查询参数按参数名字典序排bucket在prefix前面参数值里的/编码成%2F。第四到第七行是参与签名的头按名字字典序排每行格式是小写头名:值冒号后没有空格。第八行是空行不能漏它是头和头列表的分隔。第九行是参与签名的头名列表用分号连接也必须按字典序。第十行是请求体的 SHA256 十六进制值。这十行里任意一行出错签名就废。我的经验是把构造好的规范请求串打印出来存到日志里脱敏后然后跟平台文档里的示例逐字符比对。肉眼比对一次比盯着代码猜两小时有用得多。3.3 用 Python 实现签名并调用接口把上面的流程写成代码大概是这个结构import hashlib import hmac import base64 import datetime import requests AK your-access-key-id SK your-secret-access-key HOST api.example.com REGION cn-north-1 SERVICE storage def sha256_hex(data: str) - str: return hashlib.sha256(data.encode(utf-8)).hexdigest() def hmac_sha256(key: bytes, msg: str) - bytes: return hmac.new(key, msg.encode(utf-8), hashlib.sha256).digest() def build_canonical_request(method, path, query, headers, body): signed_header_names sorted(h.lower() for h in headers.keys()) canonical_headers .join( f{name}:{headers[[k for k in headers if k.lower() name][0]].strip()}\n for name in signed_header_names ) return \n.join([ method, path, query, canonical_headers, ;.join(signed_header_names), sha256_hex(body), ]) def sign_request(): now datetime.datetime.utcnow() amz_date now.strftime(%Y%m%dT%H%M%SZ) date_stamp now.strftime(%Y%m%d) body {filename:app.log} body_hash sha256_hex(body) headers { Content-Type: application/json, Host: HOST, X-Sign-Date: amz_date, X-Sign-Content-Sha256: body_hash, } canonical_request build_canonical_request( POST, /api/v1/files/upload, bucketdemoprefixlogs%2F2024, headers, body ) credential_scope f{date_stamp}/{REGION}/{SERVICE}/request string_to_sign \n.join([ HMAC-SHA256, amz_date, credential_scope, sha256_hex(canonical_request), ]) k_date hmac_sha256((SK SK).encode(utf-8), date_stamp) k_region hmac_sha256(k_date, REGION) k_service hmac_sha256(k_region, SERVICE) k_signing hmac_sha256(k_service, request) signature hmac.new( k_signing, string_to_sign.encode(utf-8), hashlib.sha256 ).hexdigest() headers[Authorization] ( fHMAC-SHA256 Credential{AK}/{credential_scope}, fSignedHeaders{content-type;host;x-sign-date;x-sign-content-sha256}, fSignature{signature} ) return headers, body headers, body sign_request() resp requests.post( fhttps://{HOST}/api/v1/files/upload?bucketdemoprefixlogs%2F2024, headersheaders, databody.encode(utf-8), ) print(resp.status_code, resp.text)跑通这段代码的关键不是代码本身而是规范化规则要对齐。特别是build_canonical_request里那几行拼接换行符的位置必须完全符合规范。我在写这段的时候第一版漏了头列表后面那个空行排查了快一个小时。提示调试阶段把canonical_request、string_to_sign、signature三个中间值都打印出来与平台文档的示例对比。等调通了再把日志级别降下来避免这些中间值进入生产日志。3.4 用 curl 和 Postman 验证代码跑通了建议再用命令行验证一遍确认是签名逻辑正确而不是某个库帮了忙。可以先用脚本生成签名然后把结果拼成 curl 命令curl -X POST https://api.example.com/api/v1/files/upload?bucketdemoprefixlogs%2F2024 \ -H Content-Type: application/json \ -H X-Sign-Date: 20240521T083000Z \ -H X-Sign-Content-Sha256: body的sha256 \ -H Authorization: HMAC-SHA256 CredentialAK/20240521/cn-north-1/storage/request, SignedHeaderscontent-type;host;x-sign-date;x-sign-content-sha256, Signature签名字符串 \ -d {filename:app.log}注意 curl 里手写查询串时%2F不要被 shell 展开加单引号包住整条 URL。Postman 里则要注意它默认会对 URL 做一次编码可能导致你传的%2F被二次编码成%252F签名就对不上了。这类工具层的好心帮忙是签名排查里的高频干扰源遇到莫名其妙的签名错误先怀疑工具改了你的输入。4. 密钥管理的安全实践泄露一次等于全盘失守签名机制本身是可靠的绝大多数事故不是因为算法被破解而是因为密钥管理出了问题。这一节讲几个我在实际项目里验证有效的做法。4.1 泄露的常见路径先看泄露是怎么发生的知道路径才好堵泄露路径典型场景风险等级硬编码进代码脚本、配置文件里直接写 SK提交到 Git极高打进镜像Dockerfile 里 ENV 写死密钥镜像推到公共仓库极高写进前端把 SK 放在 JS 里让浏览器直接调用极高明文存配置中心配置中心无权限控制所有人可见高打印进日志异常堆栈里带出密钥日志被归档上传高员工离职带走个人电脑上留存的配置文件未清理中这里面最要命的是代码仓库。Git 的历史是不可变的你把密钥提交进去、发现不对再删掉历史记录里依然留着。公开仓库几分钟内就会被自动化爬虫扫走。我见过最快的一次从提交到异常调用不到四分钟。4.2 轮换、最小权限、IP 白名单三条防线按优先级排最小权限是第一位的。给每把密钥只授予它真正需要的操作。一个只负责读取日志的脚本就只给读权限不给写、不给删。这样即使密钥泄露攻击者能造成的破坏也是有上限的。IP 白名单是第二位的。如果调用来源的出口 IP 是固定的就在密钥上绑定 IP 白名单。这样密钥即使泄露从别的网络环境也调不通。对于部署在固定机房的业务这条几乎零成本。定期轮换是第三位的。轮换的意义不在于密钥本身会过期而在于缩短窗口期。一把用了三年的密钥你无法确认这三年里有没有人抄走过。建议的节奏是高风险密钥 90 天一轮普通业务密钥 180 天一轮。轮换时采用双密钥并行策略——先上新密钥观察一段时间确认无异常调用再删旧密钥。直接删旧密钥很容易把某个没人记得的服务打挂。注意轮换不是删掉重建那么简单。你得先确认这把密钥被哪些服务、哪些脚本、哪些人使用。没有台账的密钥轮换就是一场冒险。4.3 配置文件的正确存放方式密钥不能进代码仓库那放哪几个可行方案按推荐度排密钥管理服务。云平台一般提供专门的密钥托管服务代码运行时通过临时凭证或 SDK 拉取。这是最规范的做法缺点是引入了一次网络依赖。环境变量注入。由部署流程在启动容器时注入配置文件里只留占位符。注意环境变量在容器里可以通过某些接口读到所以只适用于单租户的机器。本地加密文件 主密钥。文件加密存放解密用的主密钥单独管理。适合没有密钥管理服务的自建环境。权限收紧的配置文件。文件权限设成 600属主是运行服务的专用账号。这是最低要求不能再放松了。不管用哪种方式有一条红线密钥绝对不能出现在会被打包、会被上传、会被分享的文件里。.gitignore要提前配好CI 流程里加一道密钥扫描把误提交拦在合入之前。5. 常见报错与排查实录签名相关的报错信息普遍很含糊基本只有签名不匹配四个字。这一节我把踩过的坑整理成速查表遇到问题按顺序过一遍。5.1 签名不匹配类问题速查表现象可能原因排查方法签名不匹配参数完全正确参与签名的头列表与实际发送的头不一致打印两边头列表比对加了个头就报错新增的头没加进签名列表或者加了但没发送检查 SignedHeaders 与实际头是否一一对应本地能跑线上报错线上有代理加了头或改了 Host抓包看实际发出的请求只有带特殊字符的参数报错URL 编码方式与规范不一致逐字符比对编码结果换个语言实现就报错换行符类型不同CRLF vs LF确认用的是 LF偶尔报错偶尔正常时间戳偏差临界值附近加时钟同步扩大排查范围这里面的头号杀手是代理改头。请求经过网关、负载均衡、代理时中间层可能自动加上X-Forwarded-For、改掉Host、甚至重新编码 URL。如果这些头参与了签名签名立刻失效。解决办法是把参与签名的头限定在你完全可控的那几个别把容易变的头放进去。5.2 时间与时区类问题时间是签名排查里最容易被忽略的一环。判断方法很简单看服务端返回的错误信息里有没有提到时间。如果有直接对齐时间源就行如果没有就先按时间问题处理成本最低。几个具体操作在容器里执行date -u确认 UTC 时间与宿主机一致。确认宿主机启用了时间同步服务并且同步源可达。检查代码里用的是utcnow()还是now()。Python 里这两个差着时区now()拿到的是本地时间直接用必错。确认时间戳格式。是20240521T083000Z还是2024-05-21T08:30:00Z差一个字符都不行必须以文档为准。我遇到过最隐蔽的一次是容器基础镜像里没装时间同步工具宿主机时间准、容器时间在长时间运行后慢慢漂移漂了两周终于超过 15 分钟窗口然后所有请求突然全挂。这种问题排查起来特别费劲因为在出问题之前一切都看起来正常。建议把时间偏差做成一个监控指标主动发现比被动救火强得多。5.3 权限与限流类问题还有一类错误容易被误判成签名问题其实是权限或限流403 但错误码明确是权限不足这不是签名错是策略没配对。检查子账号绑定了哪些策略、策略里的资源 ARN 是否精确匹配、动作是否包含你要调用的操作。429 请求过于频繁签名完全正确只是被限流了。这种情况需要退避重试而不是去改签名代码。密钥被禁用或已删除签名算法没错但密钥本身失效了。这种情况服务端通常会返回明确的错误码看到直接去控制台确认密钥状态。区分方法很直接看错误码不要看错误描述。签名类错误码、权限类错误码、限流类错误码是完全不同的编号抓错了方向排查就是白费力气。6. 几个容易被忽略的实操心得前面讲的都是应该怎么做这一节说几个我用真金白银换来的经验文档里基本不会写。6.1 临时凭证的刷新要留提前量用临时凭证的时候最常见的坑是在凭证过期的那一刻还在发请求。你拿到一个有效期 1 小时的凭证代码里判断过期了再刷新结果正好在过期前一秒发起请求服务端处理时已经过期返回认证失败。正确做法是设置一个刷新阈值比如剩余有效期少于 15% 就主动刷新并且用锁保证并发场景下只有一个线程去刷新其他线程等待。这样既不会频繁刷新也不会卡在临界点。还有一个细节刷新失败要有兜底。刷新接口本身也可能因为网络抖动失败如果你的代码在刷新失败后直接抛异常整个服务就不可用了。合理做法是保留上一次的有效凭证在它真正过期前继续重试刷新。6.2 日志脱敏要在源头做密钥进日志这事很多团队是在日志平台层面做过滤的。但我觉得这个位置太靠后了。日志从产生到落盘再到上传中间经过的环节太多任何一个环节的配置失误都会让脱敏失效。更稳的做法是在代码里就不打印敏感字段。封装一个专门的请求对象这个对象在__repr__或者toString里永远不包含 SK 和签名中间值。调试的时候需要看就临时打开一个专门的调试开关用完立刻关掉。还有个隐蔽的点异常堆栈也会带数据。如果某个第三方库在抛异常时把整个请求对象序列化进了消息里SK 就跟着进日志了。上线前把异常信息过一遍比事后补救便宜得多。6.3 多环境一定要用不同的密钥开发、测试、预发、生产每个环境用独立的密钥别图省事共用一套。原因不只是隔离风险更重要的是监控能对得上。如果所有环境的调用都混在同一把密钥下你根本分不清哪些是测试流量、哪些是生产流量异常调用的告警也就失去了意义。分环境之后还能做一件事给测试环境的密钥设置更宽松的限流和更短的轮换周期生产环境的密钥则保持严格策略。这样测试同学不会因为限流被打断生产环境的暴露面也被压到最小。另外密钥的命名要带环境标识和用途比如prod-log-shipper-2024。这看着是个小事但当你手里有几十把密钥的时候一个好名字能省下很多翻台账的时间。我在上一家公司推动过这件事把原来一百多把无名密钥治理成了带环境、带用途、带负责人、带到期时间的规范命名之后每次排查问题的平均耗时下降了将近一半。这个投入产出比比很多技术优化都划算。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →