从本篇起,专栏进入「V4 签名实战」系列:按 Python → Java → C++ 轮流手写阿里云两种 V4 签名,每篇配套一个可直接导入运行的完整工程(装好依赖、替换 AK/SK 即可跑)。本篇:Python 手写 OSS V4 签名(OSS4-HMAC-SHA256) 完成 PutObject 上传,并用官方文档示例离线证明实现正确。
配套完整工程(可直接导入编辑器运行):
https://github.com/zhoubiao188/aliyun-v4-signature-examples/tree/main/python-oss-putobject-v4
一、为什么要手写签名?
用 SDK 一行代码就完成上传,为什么还要手写签名?三个真实理由:
- 排查线上 403:
SignatureDoesNotMatch是 OSS 高频问题,不看懂签名串构造,你连日志都读不明白; - SDK 覆盖不到的场景:自研网关、边缘设备、特殊语言运行时里没有官方 SDK,签名是你唯一能自己攒出来的东西;
- 理解云上鉴权的本质:V4 签名与 AWS SigV4 同源,学会一套,看懂一片。
二、OSS V4 签名原理:四步图解
① CanonicalRequest(规范化请求)
= HTTP方法 \n 规范化URI \n 规范化Query \n 规范化Headers \n AdditionalHeaders \n UNSIGNED-PAYLOAD
② StringToSign(待签名字符串)
= "OSS4-HMAC-SHA256" \n <x-oss-date> \n <日期>/<地域>/oss/aliyun_v4_request \n SHA256hex(①)
③ SigningKey(派生密钥,HMAC 链)
kDate = HMAC-SHA256( "aliyun_v4" + SK , 日期 )
kRegion = HMAC-SHA256( kDate , 地域 )
kService = HMAC-SHA256( kRegion , "oss" )
kSigning = HMAC-SHA256( kService , "aliyun_v4_request" )
④ Signature 与请求头
Signature = HEX( HMAC-SHA256( kSigning , ② ) )
Authorization: OSS4-HMAC-SHA256 Credential=AK/<scope>,Signature=<④>
几个容易被忽略的规范细节(本文实现全部与官方文档逐字节对齐):
x-oss-content-sha256必须存在且值为UNSIGNED-PAYLOAD(当前 V4 只支持此值,不做真实负载哈希),规范化请求最后一段同样写UNSIGNED-PAYLOAD;- 参与签名的头:
x-oss-content-sha256必签;Content-Type、Content-MD5、所有x-oss-*头存在即参与;其他普通头(如content-disposition)想参与签名,必须在 Authorization 的AdditionalHeaders中声明; - 规范化 Headers:头名小写、按字典序排序、值去首尾空白,每个头以
\n结尾; - URI 编码:仅
A-Za-z0-9-._~不编码(空格是%20不是+),对象键路径保留/; x-oss-date必须是 UTC ISO8601 格式,如20250411T064124Z;Scope 中的日期取它的前 8 位;- 与 AWS SigV4 不同:OSS V4 默认不签 host 头,也无密钥派生中的"服务名+终止串"之外的额外段,整体更简洁。
三、工程结构与运行方式
python-oss-putobject-v4/
├── main.py # 签名实现 + PutObject 主流程(约 130 行)
├── test_signature.py # 用官方文档示例离线验证(无需网络和真实 AK)
└── requirements.txt # 仅 requests
pip install -r requirements.txt
export ALIBABA_CLOUD_ACCESS_KEY_ID=<你的AK>
export ALIBABA_CLOUD_ACCESS_KEY_SECRET=<你的SK>
export OSS_BUCKET=<你的Bucket>
python3 test_signature.py # ① 先验证实现正确性
python3 main.py --dry-run # ② 打印签名全过程,不发请求
python3 main.py # ③ 真实上传,预期 HTTP 200 + ETag
四、核心代码逐段讲解
派生密钥链(③)——注意每一步都是"上一步的输出做 key,固定字符串做 message":
def build_signing_key(secret: str, date: str, region: str) -> bytes:
k_date = hmac_sha256(("aliyun_v4" + secret).encode(), date)
k_region = hmac_sha256(k_date, region)
k_service = hmac_sha256(k_region, "oss")
return hmac_sha256(k_service, "aliyun_v4_request")
规范化请求(①)——五段拼接,头名小写排序、每行自带换行:
names = sorted(headers)
canonical_headers = "".join(f"{n}:{str(headers[n]).strip()}\n" for n in names)
canonical_query = "&".join(f"{uri_encode(k, False)}={uri_encode(str(v), False)}"
for k, v in sorted(query_items))
canonical_request = "\n".join(
[method, canonical_uri, canonical_query,
canonical_headers, additional_headers, "UNSIGNED-PAYLOAD"])
待签串与签名(②④):
scope = f"{x_oss_date[:8]}/{region}/oss/aliyun_v4_request"
string_to_sign = "\n".join(
["OSS4-HMAC-SHA256", x_oss_date, scope, sha256_hex(canonical_request.encode())])
signing_key = build_signing_key(sk, x_oss_date[:8], region)
signature = hmac.new(signing_key, string_to_sign.encode(), hashlib.sha256).hexdigest()
authorization = (f"OSS4-HMAC-SHA256 Credential={ak}/{scope},"
f"Signature={signature}")
发起请求:把 Authorization、x-oss-date、x-oss-content-sha256: UNSIGNED-PAYLOAD、content-type 放进请求头,requests.put(url, data=body) 即可(requests 自动附加的 User-Agent 等头不参与签名,无需处理)。
五、用官方示例证明实现正确(本文最硬核的部分)
手写签名最大的痛点是"我不知道我对没对"。解法:把官方文档《在Header中包含V4签名》里的完整示例做成测试向量(test_signature.py),它的期望值全部来自官方文档原文:
[PASS] CanonicalRequest 的 SHA256(官方示例值)
c46d96390bdbc2d739ac9363293ae9d710b14e48081fcb22cd8ad54b63136eca
[PASS] 用文档 SigningKey 验证 StringToSign(HMAC 结果 = 官方签名)
053edbf550ebd239b32a9cdfd93b0b2b3f2d223083aa61f75e9ac16856d61f23
[PASS] 本工程 SigningKey(官方 SDK 公式,仅供对照)
第一条通过,说明规范化请求的每一处换行、排序、编码都与官方逐字节一致——这是手写签名最容易翻车的地方;第二条通过,说明待签串构造正确(用文档给的 SigningKey 直接 HMAC 我的 StringToSign,得到与文档完全相同的最终签名)。
一个意料之外的发现:文档展示的 SigningKey 中间值,用文档标注的 SK/日期/地域按文档公式算不出来(文档也自我声明"仅供参考,实际以自己计算的结果为准")。我对照了官方 Python SDK(oss2/auth.py 的 __get_signing_key)源码,派生链与本文实现逐行一致——所以以 SDK 公式为准,别去背文档那个中间值。
六、踩坑清单
- 头名大小写与排序:规范化头必须小写 + 字典序,
X-Oss-Date和x-oss-date是两个东西; - "存在即参与"的头漏签:请求里带了
content-type却没放进规范化请求,必得SignatureDoesNotMatch;反之,没带的头不要乱签; - 时间必须 UTC:
x-oss-date用本地时间或格式不对,轻则签名不匹配,重则RequestTimeTooSkewed;本机时钟漂移也会 403; UNSIGNED-PAYLOAD是当前唯一支持的取值:照搬 AWS SigV4 的"真实负载 SHA256"写法反而会失败;- URI 编码用
%20不用+:urllib.parse.quote而不是quote_plus,保留字符只有A-Za-z0-9-._~; - virtual-hosted 与 path 风格:
bucket.oss-cn-hangzhou.aliyuncs.com/key的规范化 URI 是/key;如果用 path 风格(官方示例如此)则是/bucket/key,二者别混; - 调试方法:
--dry-run把 CanonicalRequest 与 StringToSign 原文打印出来,与官方文档示例逐字符比对,99% 的 403 都能当场定位。
七、小结��下一篇预告
本篇把手写 OSS V4 签名完整落地:四步图解、130 行可运行实现、官方测试向量背书。核心心法一句话:签名 = 把"你到底想对谁做什么"规范化成一串字节,再用只有你和云知道的密钥对其 HMAC。
下一篇(第 2 篇)换 Java:手写 ACS3-HMAC-SHA256 签名调用 ECS DescribeInstances,体会"全系 OpenAPI 通用签名"与 OSS 专用签名的差异(Maven 完整工程同样会在仓库里)。欢迎收藏仓库:https://github.com/zhoubiao188/aliyun-v4-signature-examples
声明:本文为作者个人实践笔记,签名细节以阿里云官方签名文档为准;文中无任何真实密钥。