天翼云API签名算法完全解析:V2 vs V4,我踩过的那些坑

0 阅读7分钟

天翼云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.cnV2AWS S3 V2(旧版)
ZOS端点[bucket].zos.ctyun.cnV4AWS 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内部自动处理:

  1. 根据你传入的endpoint自动选择V2还是V4签名
  2. safe 参数的正确用法
  3. GET请求自动带上 x-amz-content-sha256
  4. 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签名的核心要点:

  1. 先判断端点类型ctyunapi.cn → V2,zos.ctyun.cn → V4
  2. V2的坑:CanonicalQueryString的参数值必须用 safe='' 编码
  3. V4的坑:GET请求也必须带 x-amz-content-sha256 header
  4. 通用坑:Key类型要对,主账号Key和IAM Key不是到处通用的

如果不想自己处理这些细节,直接用SDK:

pip install ctyun-obs-python

上一篇我花了3天跑通天翼云OBS API,顺便写了个开源Python SDK

如果这篇对你有帮助,欢迎给仓库点个 Star:github.com/metafo333-h…

有问题欢迎在评论区留言,我会认真回复。