云选科普

用 C++ 实现 OSS V4 签名:从字节处理到回归测试

本文从 C++ 工程实践出发,拆解 OSS V4 签名中的二进制 HMAC、请求规范化、URI 编码和 UTC 时间问题,并介绍如何用 CMake、OpenSSL、libcurl 及 CTest 构建可验证的上传组件。

为什么 C++ 版 OSS V4 签名更容易出错

使用 C++ 手动实现阿里云 OSS 的 V4 签名时,算法步骤并不复杂,真正需要谨慎的是字节、排序、编码和时间处理。Python 等语言通常会替开发者隐藏一部分细节,而 C++ 需要明确区分二进制数据与 C 风格字符串。

这类实现适合需要自行控制请求流程的场景,例如:

  • 将 OSS 上传能力集成进已有 C++ 服务或桌面程序;
  • 对签名过程进行调试、审计或跨语言验证;
  • 不希望引入完整 SDK,只保留 OpenSSL、libcurl 等基础依赖;
  • 需要把签名器封装成可复用的 header-only 组件。

如果业务只是常规文件上传,官方 SDK 或服务端生成临时授权通常更省维护成本。手写签名更适合作为底层能力或学习、测试用途,投入使用前应配合测试向量和真实环境验证。

先处理四个关键工程问题

1. HMAC 结果必须按二进制保存

V4 签名会通过多次 HMAC-SHA256 派生密钥:日期密钥、区域密钥、服务密钥,最后得到签名密钥。每一步输出都是固定长度的二进制摘要,而不是可直接打印的文本。

如果把中间结果交给 strlen、c_str() 后再按 C 字符串处理,摘要中的 0x00 可能被当作结束标记,导致密钥被截断。C++ 中应使用带长度信息的 std::string 保存原始字节,并在最终生成签名时才转换为十六进制文本。

inline std::string hmac_sha256(const std::string& key,
                               const std::string& message) {
    unsigned char digest[EVP_MAX_MD_SIZE];
    unsigned int digest_size = 0;

    HMAC(EVP_sha256(),
         key.data(), static_cast<int>(key.size()),
         reinterpret_cast<const unsigned char*>(message.data()),
         message.size(), digest, &digest_size);

    return std::string(reinterpret_cast<const char*>(digest), digest_size);
}

密钥派生链可以保持为连续的二进制操作:

std::string k_date = hmac_sha256("aliyun_v4" + secret_key, date);
std::string k_region = hmac_sha256(k_date, region);
std::string k_service = hmac_sha256(k_region, "oss");
std::string k_signing = hmac_sha256(k_service, "aliyun_v4_request");

std::string signature = to_hex(hmac_sha256(k_signing, string_to_sign));

2. 规范化请求头要稳定排序

签名输入中的请求头需要统一转为小写,并按照字典序排列。使用 std::map<std::string, std::string> 保存规范化头,可以利用容器自身的排序行为,避免手工排序遗漏或顺序不稳定。

每个规范化请求头都应以换行符结束,同时要统一处理值两端的空白字符。后续如果改用 std::unordered_map,即使请求本身完全相同,也可能因为遍历顺序改变而得到不同签名。

3. URI 编码需要遵循 OSS 规则

通用 URL 编码函数不一定符合 OSS V4 的规范。路径编码通常需要保留 /,只允许 A-Z、a-z、0-9 以及 -._~ 原样通过,空格则应编码为 %20。因此,直接复用某些库函数时,应先核对其对空格、斜杠和波浪号的处理方式。

对于签名器,单独实现一个明确的 RFC 3986 风格编码函数,通常更容易审查和测试。路径编码和查询参数编码也应根据签名规范分别处理,不能简单地对完整 URL 进行一次替换。

4. 签名时间必须使用 UTC

x-oss-date 的格式类似 20261009T145101Z,末尾的 Z 表示 UTC。生成时间时应使用 gmtime_r 或平台对应的 UTC 接口,再通过 strftime 格式化。

如果误用本地时间,客户端时钟可能与服务端相差数小时,最终收到时间偏差相关错误。除了代码使用 UTC,部署环境还应保持系统时钟同步。

一个便于维护的项目结构

可以把签名逻辑、网络请求和测试拆开:

oss-v4-cpp/
├── CMakeLists.txt
├── include/
│   └── oss_v4_signer.hpp
├── src/
│   └── main.cpp
└── test/
    └── test_signature.cpp

其中,oss_v4_signer.hpp 负责摘要、编码、规范化请求和 Authorization 生成;main.cpp 只处理配置读取与 libcurl 上传;测试程序则验证固定输入下的中间结果。

CMake 可以只为测试目标链接 OpenSSL Crypto,而把 libcurl 留给真正执行网络请求的程序:

find_package(OpenSSL REQUIRED)
find_package(CURL REQUIRED)

add_executable(v4_selftest test/test_signature.cpp)
target_include_directories(v4_selftest PRIVATE include)
target_link_libraries(v4_selftest PRIVATE OpenSSL::Crypto)

enable_testing()
add_test(NAME v4_selftest COMMAND v4_selftest)

在 Ubuntu 等 Linux 环境中,通常需要安装 CMake、编译器、OpenSSL 开发包和 libcurl 开发包。macOS 使用 Homebrew 安装 OpenSSL 后,如果 CMake 找不到头文件或库文件,可以显式指定 OPENSSL_ROOT_DIR。

V4 签名的主要拼接过程

规范化请求由 HTTP 方法、规范化 URI、规范化查询字符串、规范化请求头、已签名头列表以及负载摘要标识共同组成。采用不计算请求体摘要的模式时,相关字段使用 UNSIGNED-PAYLOAD,并且请求头中的值必须和签名计算时保持一致。

std::string canonical_headers;
for (const auto& item : headers) {
    canonical_headers += item.first + ":" + trim(item.second) + "\n";
}

std::string canonical_request =
    method + "\n" +
    canonical_uri + "\n" +
    canonical_query + "\n" +
    canonical_headers + "\n" +
    signed_headers + "\n" +
    "UNSIGNED-PAYLOAD";

随后计算规范化请求的 SHA256 十六进制摘要,组装待签名字符串:

std::string scope = date + "/" + region + "/oss/aliyun_v4_request";

std::string string_to_sign =
    "OSS4-HMAC-SHA256\n" +
    oss_date + "\n" +
    scope + "\n" +
    sha256_hex(canonical_request);

最终,签名结果写入 Authorization 请求头,同时发送 x-oss-date、x-oss-content-sha256、content-type 等参与签名的字段。使用 libcurl 时,建议让生成签名的头和值与实际发送的头来自同一份数据,避免出现计算内容与网络请求不一致的问题。

用固定测试向量做回归验证

签名代码不应只通过一次真实上传来判断是否正确。网络请求成功可能掩盖了某些边界问题,而固定测试向量可以在没有访问密钥和网络连接的情况下,检查以下内容:

  • 规范化请求的 SHA256 是否匹配预期;
  • 待签名字符串的组成是否正确;
  • 密钥派生链的每个阶段是否按原始字节传递;
  • 最终签名是否与规范中的结果一致。

测试程序可以使用官方示例中的固定请求参数,并分别断言规范化请求摘要、派生签名密钥和最终签名。通过 CTest 接入构建流程后,每次修改编码、排序或换行逻辑,都能在本地快速发现回归。

跨语言验证也很有价值:让 C++ 实现与另一种独立实现使用完全相同的输入,逐项比较中间结果,比只比较最终 HTTP 状态码更容易定位问题。

上线前的检查清单

  • HMAC 中间值是否始终按带长度的二进制数据处理;
  • 摘要缓冲区是否使用 EVP_MAX_MD_SIZE,并以实际长度为准;
  • 请求头是否统一小写、去除多余空白并稳定排序;
  • 是否区分路径编码、查询参数编码和完整 URL;
  • 时间是否由 UTC 接口生成,服务器时钟是否同步;
  • 实际发送的请求头是否与签名输入完全一致;
  • 是否使用固定测试向量覆盖关键中间结果;
  • 访问密钥是否通过环境变量或安全配置注入,避免写入源码和日志。

对于个人站长和小团队,如果只是让后端上传文件,通常可以优先比较官方 SDK、临时凭证和服务端代理三种方案的维护成本。只有在需要精确控制 HTTP 请求、减少依赖或跨语言复刻签名流程时,手写 C++ 签名器才更具实际价值。

继续浏览

还想继续看,可以再看这些文章

适合想继续在同一主题下横向阅读、对比不同切入角度的用户。