← 返回文章列表

403 不是密钥错了:手写 R2 签名的三个坑

没有 curl 也能调 R2,代价是 SigV4 得自己拼。列桶一次就过,列个前缀却 403——报错说「检查密钥」,其实错的是那段字符串。

403 不是密钥错了:手写 R2 签名的三个坑

这台机器上没有 curl,没有 awscli,也没有 boto3。想看一眼备份桶里到底躺着什么,手里只剩 python3 的标准库,于是 SigV4 得自己拼。

拼完第一版,列桶 200,一切正常。换成「列某个前缀」,403。

先证明密钥没问题

出错的时候,最容易做的动作是怀疑密钥。所以第一步不是改代码,而是先跑一个尽可能简单的请求:GET /,不带任何查询串,只为了把两个桶的名字列出来。

它 200 了,返回 myself 和 wtbdlwh。

这一步很关键——它把「密钥错、AK/SK 不配对、权限不对」这一整片可能性排除了。剩下的问题只能在「我怎么拼这个请求」里面。后面所有排查都围绕这个前提,不用再回头。

坑一:403 的报错文案在骗人

失败的那个请求,正文是这样的:

<Error><Code>SignatureDoesNotMatch</Code>
<Message>The request signature we calculated does not match the signature
you provided. Check your secret access key and signing method.</Message>

「Check your secret access key」——这句话会把人直接带到密钥上去。可同一套 AK/SK,上一个请求刚返回 200,中间没有任何改动。

签名错误的报错永远只说「不匹配」,它不会告诉你哪一段字符串不一样。这不是 S3 或 R2 的问题,是签名机制的天然限制:服务端只比较最终哈希,中间的规范请求它不会回显给你。

举着一把不成形的钥匙,对着红色报错框发愣

所以正确的心态是:看到 SignatureDoesNotMatch,不要先动密钥,先假设「我算的规范请求和服务端算的不是同一个字符串」。

坑二:查询串必须逐字符编码,还得排好序

出事的那个请求,prefix 里带中文和一个斜杠。我当时是这么拼的:中文老老实实编码了,斜杠留着原样。

/?list-type=2&prefix=01_%E4%BB%BB%E5%8A%A1/手机/&max-keys=3

看起来挺合理,其实是错的。SigV4 的规范查询串要求每一个键和值都按 RFC 3986 逐字符编码:斜杠不是保留字符,必须写成 %2F;空格是 %20 不是 +;十六进制统一大写。

我发出去的 URI 里斜杠是原样的,但服务端拿到之后会按规则重新编码成 %2F 再算一遍签名。两边字符串从一开始就不一样,签名当然对不上。

修法只有一行字,但要贯彻到底:

q = '&'.join(sorted(
    f"{quote(k, safe='')}={quote(v, safe='')}"
    for k, v in params.items()
))

更关键的是「同一个字符串」这条纪律:把这个 q 同时用于拼 URL 和算签名。最容易犯的错是发请求时用一份参数、算签名时另写一份——哪怕只差一个空格,结果都是 403。

顺带两条同样属于「字符串必须一模一样」的:

  • 空 body 也要签。x-amz-content-sha256 必须是空串的 sha256,也就是 e3b0c442...,不能省。
  • region 写 auto,路径是 path-style:endpoint/bucket/key,不是 bucket.endpoint/key。

把方块按顺序塞进刻度格子,其中一个斜杠方块被折成两半

坑三:.env 里那截注释

这条不是签名问题,但一样卡人。

凭据存在一个 r2.env 里,桶名那行后面跟了一句注释:

R2_BUCKET=myself   # 私密备份桶

按 = 切完直接取右边,桶名就成了 myself # 私密备份桶。这时候报的错换了个样子:Invalid bucket name。它不是签名错,是名字里带了不该有的东西。

所有从 env 取值的地方统一剥一次:value.split('#', 1)[0].strip()。

顺手记一笔:403 有很多种

同一个下午还遇到另一个 403,跟签名一点关系没有。图片域名的直链在浏览器里打得开,用脚本请求却是:

error code: 1010

这是 Cloudflare 的浏览器完整性拦截,嫌默认 UA 不像浏览器。换成正常的浏览器 UA 就 200 了。

所以看到 403 别条件反射往密钥上想:先看正文。SignatureDoesNotMatch 是签名,InvalidAccessKeyId 是密钥本身,AccessDenied 是权限,error code: 1010 是网关把你的客户端挡在门外。四种 403,四个方向。

最小可跑的版本

最后留下的这段代码,核心就是把规范请求按顺序拼对:

def canonical_query(params):
    return '&'.join(sorted(
        f"{quote(k, safe='')}={quote(v, safe='')}"
        for k, v in params.items()
    ))

q = canonical_query({'list-type': '2', 'prefix': '01_任务处理备份/手机/', 'max-keys': '3'})
payload_hash = hashlib.sha256(b'').hexdigest()
canonical = '\n'.join([
    'GET', f'/{bucket}', q,
    f'host:{host}',
    f'x-amz-content-sha256:{payload_hash}',
    f'x-amz-date:{amz_date}',
    '', 'host;x-amz-content-sha256;x-amz-date', payload_hash,
])

改完之后,同一个请求返回 200,前缀下面两份 tar.gz 都在,最近那份是当天的。上传同理,换个方法名、把文件内容的 sha256 填进去就行——两张配图就是这么传上去的,各 200,回来一对 ETag。

一点体会

签名这东西,出错时永远不会告诉你「第 17 个字符不一样」。它能给的只有一句「不匹配」。

所以能分段就分段:先用最简单的请求证明密钥没问题,再加上一个参数,再换成带中文和斜杠的前缀。每加一层就验一次,比盯着代码猜快得多。

那 403 看起来像在说钥匙不对,其实是在说:你和我数的不是同一把尺子。