天翼云API签名算法完全解析:V2 vs V4,我踩过的那些坑
上篇写了怎么用Python跑通天翼云OBS API,评论区有几个人问我签名的事。这篇专门讲签名,因为这块确实有坑,而且坑得很隐蔽。
引言
我在对接天翼云OBS的时候,遇到过这个报错:
SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided.
这个错误让人抓狂,因为你根本不知道哪里出了问题——是密钥错了?是字符串没拼对?还是时间戳的问题?
花了大半天时间,我才搞清楚天翼云的签名体系。核心发现:天翼云有两套完全不同的签名算法,用在不同的端点上,不能混用。
第一坑:你以为只有一套签名?
天翼云OBS对象存储有两套API端点,对应两套签名算法:
| 端点类型 | 域名格式 | 签名算法 | 兼容标准 |
|---|---|---|---|
| OOS端点 | oos-[region].ctyunapi.cn | V2 | AWS S3 V2(旧版) |
| ZOS端点 | [bucket].zos.ctyun.cn | V4 | AWS S3 V4(Signature V4) |
如何判断你该用哪套?
看你的访问域名:
- 域名包含
ctyunapi.cn→ 用V2 - 域名包含
zos.ctyun.cn→ 用V4
我最开始的错误:直接把AWS的V4签名代码搬过来用在OOS端点上,然后一直报SignatureDoesNotMatch。原来OOS根本不认V4。
V2签名完整实现
V2签名是AWS S3早期的签名方式,天翼云OOS端点用的就是这套,有几个地方和标准AWS略有不同。
import hmac
import hashlib
import base64
import urllib.parse
from datetime import datetime, timezone
def sign_v2(
access_key: str,
secret_key: str,
method: str, # "GET" / "PUT" / "DELETE" 等
bucket: str,
key: str, # 对象路径,如 "folder/file.txt"
params: dict = None, # Query string 参数
headers: dict = None,
host: str = "oos-cn.ctyunapi.cn",
) -> dict:
"""
天翼云 OOS 端点 V2 签名实现
返回包含 Authorization 头的完整 headers 字典
"""
if params is None:
params = {}
if headers is None:
headers = {}
# 1. 时间戳
now = datetime.now(timezone.utc)
date_str = now.strftime("%a, %d %b %Y %H:%M:%S GMT")
# 2. 构建 CanonicalizedAmzHeaders(只处理 x-amz- 开头的头)
amz_headers = {
k.lower(): v for k, v in headers.items()
if k.lower().startswith("x-amz-")
}
canonicalized_amz = ""
if amz_headers:
sorted_keys = sorted(amz_headers.keys())
canonicalized_amz = "\n".join(
f"{k}:{amz_headers[k]}" for k in sorted_keys
) + "\n"
# 3. 构建 CanonicalizedResource
# 注意:路径中的斜杠不编码,其他字符正常编码
encoded_key = urllib.parse.quote(key, safe="/")
resource = f"/{bucket}/{encoded_key}"
# 4. 构建 CanonicalQueryString(这里有个大坑,下面专门说)
if params:
# 坑:必须用 safe='' 对参数值进行编码
# 天翼云要求参数值完全编码,空格变%20而非+
sorted_params = sorted(params.items())
query_parts = []
for k, v in sorted_params:
encoded_k = urllib.parse.quote(str(k), safe="")
encoded_v = urllib.parse.quote(str(v), safe="")
query_parts.append(f"{encoded_k}={encoded_v}")
resource += "?" + "&".join(query_parts)
# 5. 组装 StringToSign
content_md5 = headers.get("Content-MD5", "")
content_type = headers.get("Content-Type", "")
string_to_sign = "\n".join([
method.upper(),
content_md5,
content_type,
date_str,
canonicalized_amz + resource,
])
# 6. HMAC-SHA1 签名
signature = base64.b64encode(
hmac.new(
secret_key.encode("utf-8"),
string_to_sign.encode("utf-8"),
hashlib.sha1,
).digest()
).decode("utf-8")
# 7. 组装最终 headers
final_headers = {
**headers,
"Date": date_str,
"Authorization": f"OOS {access_key}:{signature}",
"Host": host,
}
return final_headers
使用示例:
import requests
headers = sign_v2(
access_key="your_access_key",
secret_key="your_secret_key",
method="GET",
bucket="my-bucket",
key="folder/test.txt",
host="oos-cn.ctyunapi.cn",
)
url = "https://oos-cn.ctyunapi.cn/my-bucket/folder/test.txt"
resp = requests.get(url, headers=headers)
print(resp.status_code)
V4签名完整实现
V4是AWS的新版签名,ZOS端点使用这套。实现更复杂,但更安全。
import hmac
import hashlib
import urllib.parse
from datetime import datetime, timezone
def _sign(key: bytes, msg: str) -> bytes:
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
def _get_signing_key(secret_key: str, date: str, region: str, service: str) -> bytes:
"""推导签名密钥(四层HMAC链)"""
k_date = _sign(("AWS4" + secret_key).encode("utf-8"), date)
k_region = _sign(k_date, region)
k_service = _sign(k_region, service)
k_signing = _sign(k_service, "aws4_request")
return k_signing
def sign_v4(
access_key: str,
secret_key: str,
method: str,
bucket: str,
key: str,
region: str = "cn-south-1",
params: dict = None,
headers: dict = None,
payload: bytes = b"",
host: str = None,
) -> dict:
"""
天翼云 ZOS 端点 V4 签名实现
返回包含 Authorization 头的完整 headers 字典
"""
if params is None:
params = {}
if headers is None:
headers = {}
service = "s3"
endpoint_host = host or f"{bucket}.zos.ctyun.cn"
# 1. 时间戳
now = datetime.now(timezone.utc)
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
date_stamp = now.strftime("%Y%m%d")
# 2. payload hash
# 坑:GET请求没有body,但是也必须传 UNSIGNED-PAYLOAD 或者空串的hash
if payload:
payload_hash = hashlib.sha256(payload).hexdigest()
else:
payload_hash = hashlib.sha256(b"").hexdigest()
# 注意:这里不能用 "UNSIGNED-PAYLOAD",天翼云ZOS要求实际hash
# 3. 构建必要的 headers
canonical_headers_dict = {
"host": endpoint_host,
"x-amz-content-sha256": payload_hash,
"x-amz-date": amz_date,
**{k.lower(): v for k, v in headers.items()},
}
sorted_header_keys = sorted(canonical_headers_dict.keys())
canonical_headers = ""
signed_headers_list = []
for k in sorted_header_keys:
canonical_headers += f"{k}:{canonical_headers_dict[k]}\n"
signed_headers_list.append(k)
signed_headers = ";".join(signed_headers_list)
# 4. CanonicalQueryString(V4要求按key字母序排列,值要URI编码)
canonical_qs = ""
if params:
sorted_params = sorted(params.items())
parts = []
for k, v in sorted_params:
parts.append(
urllib.parse.quote(str(k), safe="") + "=" +
urllib.parse.quote(str(v), safe="")
)
canonical_qs = "&".join(parts)
# 5. CanonicalRequest
canonical_uri = "/" + urllib.parse.quote(key, safe="/")
canonical_request = "\n".join([
method.upper(),
canonical_uri,
canonical_qs,
canonical_headers,
signed_headers,
payload_hash,
])
# 6. StringToSign
credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
string_to_sign = "\n".join([
"AWS4-HMAC-SHA256",
amz_date,
credential_scope,
hashlib.sha256(canonical_request.encode("utf-8")).hexdigest(),
])
# 7. 计算签名
signing_key = _get_signing_key(secret_key, date_stamp, region, service)
signature = hmac.new(
signing_key,
string_to_sign.encode("utf-8"),
hashlib.sha256,
).hexdigest()
# 8. 组装 Authorization
authorization = (
f"AWS4-HMAC-SHA256 "
f"Credential={access_key}/{credential_scope}, "
f"SignedHeaders={signed_headers}, "
f"Signature={signature}"
)
# 9. 最终 headers
final_headers = {
**headers,
"Host": endpoint_host,
"x-amz-date": amz_date,
"x-amz-content-sha256": payload_hash,
"Authorization": authorization,
}
return final_headers
三个最容易错的地方
在对接过程中,我踩了不少坑,这里列出最典型的三个:
坑1:AccessKey类型搞混了
天翼云控制台可以创建两种Key:
- 主账号AK/SK:权限最大,危险
- 子账号(IAM)AK/SK:可以精细化控制权限
这两种Key从字面上看没区别,都是一串字符。但用主账号Key调用某些子账号独有的端点会报403,反之亦然。
我的建议:开发测试用主账号Key,上生产换IAM Key,并且只给IAM Key分配实际需要的权限(GetObject/PutObject之类),不要给全量。
坑2:V2签名的safe参数
这是最隐蔽的坑。
Python的 urllib.parse.quote() 有个 safe 参数,默认是 safe='/',意思是斜杠不编码。但在V2签名的CanonicalQueryString里,参数值需要用 safe='' 来编码,即所有特殊字符都要编码。
错误写法(常见):
# 错!safe默认是'/',会导致包含斜杠的参数值签名不匹配
encoded_v = urllib.parse.quote(str(v))
正确写法:
# 对!safe='' 确保所有特殊字符都被编码
encoded_v = urllib.parse.quote(str(v), safe="")
乍一看好像没什么区别,但如果你的参数值里恰好有斜杠(比如对象路径作为参数传递时),就会导致客户端算出来的签名和服务端算出来的不一样,报 SignatureDoesNotMatch。
坑3:GET请求的 x-amz-content-sha256
在V4签名里,所有请求(包括GET)都需要 x-amz-content-sha256 这个header。
GET请求没有body,很容易以为不用传这个头。但天翼云ZOS端点强制要求这个头存在,而且它的值必须是空字符串的SHA256哈希:
# GET请求没有body,但必须传这个header
import hashlib
empty_hash = hashlib.sha256(b"").hexdigest()
# = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
headers["x-amz-content-sha256"] = empty_hash
如果你漏掉这个header,会得到这个报错:
MissingSecurityHeader: Your request was missing a required header
直接用SDK不踩坑
上面这些坑如果让你自己处理,光测试就得花一两天。我把这些全部封装进了开源SDK:
安装
pip install ctyun-obs-python
或者直接从GitHub安装最新版:
pip install git+https://github.com/metafo333-hue/ctyun-obs-python.git
SDK处理了什么
SDK内部自动处理:
- 根据你传入的endpoint自动选择V2还是V4签名
safe参数的正确用法- GET请求自动带上
x-amz-content-sha256 - Key类型检测(IAM Key vs 主账号Key,会在日志里给出提示)
from ctyun_obs import CtyunOBSClient
# 连接OOS端点(自动用V2签名)
client = CtyunOBSClient(
access_key="your_ak",
secret_key="your_sk",
endpoint="oos-cn.ctyunapi.cn",
bucket="my-bucket",
)
# 上传文件
client.put_object("folder/test.txt", b"hello world")
# 下载文件
data = client.get_object("folder/test.txt")
print(data)
# 连接ZOS端点(自动用V4签名)
client_zos = CtyunOBSClient(
access_key="your_ak",
secret_key="your_sk",
endpoint="zos.ctyun.cn",
bucket="my-bucket",
)
# 同样的接口,SDK内部自动切换签名算法
data = client_zos.get_object("folder/test.txt")
SDK地址:github.com/metafo333-h…
小结
天翼云API签名的核心要点:
- 先判断端点类型:
ctyunapi.cn→ V2,zos.ctyun.cn→ V4 - V2的坑:CanonicalQueryString的参数值必须用
safe=''编码 - V4的坑:GET请求也必须带
x-amz-content-sha256header - 通用坑:Key类型要对,主账号Key和IAM Key不是到处通用的
如果不想自己处理这些细节,直接用SDK:
pip install ctyun-obs-python
上一篇:我花了3天跑通天翼云OBS API,顺便写了个开源Python SDK
如果这篇对你有帮助,欢迎给仓库点个 Star:github.com/metafo333-h… ⭐
有问题欢迎在评论区留言,我会认真回复。