ArkTS 直传华为云 OBS:HMAC-SHA1 Header 签名与并发上传的六个坑
最近在做一个 HarmonyOS 云相册:从本地图库挑照片和视频,直接备份到华为云 OBS,再在云端列表里看缩略图和预览。功能看起来简单,真正卡住时间的不是 UI,而是那一串 Authorization 头——服务端只回 403,不告诉你哪一行拼错了。这篇把踩过的六个坑按排查顺序记下来,顺带给出一套可复用的封装。
为什么选择端侧直传
最常见的架构是"客户端 → 应用服务器 → OBS":文件先上传到自己的服务,再由服务转发到对象存储。好处是密钥不下发,坏处也很直接——所有流量都要过一次应用服务器,相册这种一次几十个大文件的场景,带宽和连接数会先撑不住,服务端还得处理临时文件落盘与清理。
端侧直传把链路改成"应用服务器只签发一次短期授权,客户端拿着授权直接 PUT 到 OBS"。服务器从数据通道里退出来,只做控制面。代价是:签名逻辑跑在不可信的端侧,规范里任何一个字符的偏差都会被放大成 403,而且你没法在服务端偷偷修正。
一、先固定 StringToSign 的形状
OBS 的 Header 签名本质很朴素:拼一段规范化字符串,做 HMAC-SHA1,Base64 编码,塞进 Authorization 头。形状固定为:
HTTP-Verb + "\n" + Content-MD5 + "\n" + Content-Type + "\n" + Date + "\n" + CanonicalizedOBSHeaders + CanonicalizedResource
第一个坑就在这里:前四行末尾的换行一个都不能少。Content-MD5 和 Content-Type 经常为空,但空也必须占一行,也就是连续出现 "\n\n"。我第一次为了"让字符串好看点"把空行去掉,排查了半小时。另一个容易混的是:如果用 x-obs-date 而不是标准 Date 头,那么 Date 这一行要留空,把日期写进 CanonicalizedOBSHeaders,两者不能同时参与签名。
二、坑 2:x-obs- 头的收集与字典序
只收集以 x-obs- 开头的请求头,头名统一转小写,按 key 的字典序升序排列,每行写成 key:value\n;同一个头有多个值时用逗号连接并先排序。三个细节:
- 值要 trim 掉首尾空白,但不要把值内部的连续空格压缩掉,服务端是按原始值算的。
- 自己加的
x-app-trace、x-request-id这类自定义头不参与签名,误收进去必然 403。 - 排序用字节序,不要用中文 locale 的
localeCompare,不同设备的排序结果可能不一致。
调试时最有效的办法不是反复改代码,而是把本地拼出的完整 StringToSign 用可打印形式(把 \n 显示成 ↵)打出来,和服务端按同一规则生成的版本逐行 diff。
三、坑 3:CanonicalizedResource 别忘了子资源
资源部分是 /bucket/object-key。即使你用的是虚拟主机风格的域名(bucket 已经在 host 里),路径里仍然要写出 bucket。对象名里的空格、中文、斜杠要做 URI 编码,且签名与最终请求必须用同一份编码结果,先编码再拼字符串,不要在发送前又编码一次。
真正容易漏的是子资源:分片上传的 partNumber、uploadId,以及列举对象时的 prefix、marker、max-keys 等。这些参数要按白名单挑出来、按字典序排列后追加到资源后面。整单上传能过、一分片就 403,八成是 partNumber 没进 CanonicalizedResource。
四、坑 4:ArkTS 里 HMAC-SHA1 的异步与二进制
在 ArkTS 侧用 @ohos.security.cryptoFramework 做 HMAC,流程是"生成对称密钥 → createMac → init → update → doFinal",全程 Promise 异步,输出是 Uint8Array,再交给 util.Base64Helper 编码。示意代码(算法名与 API 形态请以当前 SDK 版本文档为准):
import { cryptoFramework } from '@kit.CryptoArchitectureKit';
import { util } from '@kit.ArkTS';
async function hmacSha1Base64(sk: string, data: string): Promise<string> {
const keyBlob: cryptoFramework.DataBlob = {
data: new util.TextEncoder().encodeInto(sk)
};
const symKeyGenerator = cryptoFramework.createSymKeyGenerator('HMAC');
const symKey = await symKeyGenerator.convertKey(keyBlob);
const mac = cryptoFramework.createMac('SHA1');
await mac.init(symKey);
await mac.update({ data: new util.TextEncoder().encodeInto(data) });
const out = await mac.doFinal();
const signature = new util.Base64Helper().encodeToStringSync(out.mac);
return signature;
}
// Authorization: OBS <AccessKeyId>:<signature>
这里有两个实际会踩的点:一是 TextEncoder 与字符串拼接混用时,中文对象名必须保证 UTF-8 字节一致,别用默认编码去转;二是 doFinal() 返回的字段名在不同 API 版本里可能是 mac 或 data,上线前用一条已知答案的向量做一次自检,比靠 403 猜快得多。
五、坑 5:Date、时钟偏移与 Content-Type 一致性
OBS 会校验请求时间窗(通常 ±15 分钟),而手机时钟不可信。可靠做法是:进入上传流程前先向自己的应用服务器取一次服务器时间,算出 offset = serverTime - localTime,后续所有请求都用 new Date(Date.now() + offset) 生成时间头,并且把 offset 缓存在一次上传会话内,不要每个文件各算一次。
Content-Type 与 Content-MD5 则要求"签名里写的"和"最终发出去的"逐字节一致。ArkTS 的 request.uploadFile 在 multipart 模式下会自动改写 Content-Type 并加上 boundary,这几乎必然与签名失配。传二进制对象时建议改用更底层的 http.HttpRequest,显式写全所有头,关掉框架的自动类型推断;如果签名时 Content-Type 留空,就确保发出的请求里也没有这个头。
六、坑 6:并发、取消与重试不是一回事
相册一次选几十张照片,直接 Promise.all 全部并发会同时撑爆文件句柄、内存峰值与连接池。我最后用的是固定宽度信号量,宽度取 3,配合可取消的标记:
class Semaphore {
private queue: Array<() => void> = [];
private running = 0;
constructor(private readonly width: number) {}
async acquire(): Promise<void> {
if (this.running < this.width) { this.running++; return; }
await new Promise<void>(resolve => this.queue.push(resolve));
this.running++;
}
release(): void {
this.running--;
const next = this.queue.shift();
if (next) next();
}
}
async function uploadAll(items: UploadItem[], cancelled: () => boolean) {
const sem = new Semaphore(3);
return Promise.all(items.map(async (item) => {
if (cancelled()) return { key: item.key, skipped: true };
await sem.acquire();
try {
if (cancelled()) return { key: item.key, skipped: true };
return await putObjectWithRetry(item);
} finally {
sem.release();
}
}));
}
重试策略要按错误类型分开:只有网络错误和 5xx 才做指数退避重试(例如 400ms / 800ms / 1600ms,最多 3 次);400 与 403 属于签名或参数错误,重试一百次也不会成功,应当立即中止并把 StringToSign 快照上报,避免把签名 bug 误判成网络抖动。另外务必记录已成功的对象 key,用户点"继续"时只补传失败项,不要整批重来。
取消也有讲究:点"取消备份"时,正在飞的请求不一定要强杀,但队列里未开始的任务必须立刻短路,否则界面显示已取消、后台还在偷跑并消耗流量。上面的 cancelled() 在 acquire 前后各检查一次,就是为了覆盖"排队期间被取消"这段窗口。
七、安全边界:端侧直传不等于把 AK/SK 打包进 hap
这是最需要说清楚的一点。签名逻辑放在端侧,不代表长期 AK/SK 可以放在端侧。任何硬编码在安装包、assets 或本地配置文件里的密钥都等同于公开,反编译几乎没有成本。正确的分工是:
- 应用服务器持有长期密钥,只负责按用户身份签发带有效期、限定 bucket 与对象名前缀的临时凭据(STS / 临时签名)。
- 客户端拿到凭据后在有效期内直传,超时重新向服务器申请,凭据不落持久化存储。
- 对象名带上用户维度前缀,配合服务端前缀校验,避免越权写别人的目录。
- 桶策略只开放必要的 PUT/GET,不要把 ListBucket 或删除权限放给端侧凭据。
八、一套可复用的封装分层
把上面所有东西收拢成四层,后面接别的对象存储也只换第一层:
- canonicalize 层:输入 method / headers / bucket / key / subresources,输出 StringToSign,另提供
toPrintable()供日志逐行比对。 - signer 层:输入凭据与 StringToSign,输出 Authorization,内置一条已知向量的自检。
- transport 层:显式设置全部请求头、统一超时、把响应码与响应体归一化成结构化错误。
- scheduler 层:信号量限流、取消标记、分类重试、成功项去重与进度回调。
这样分完之后,"403 排查"只需要动第一层,"换凭据来源"只动第二层,UI 层拿到的永远是进度百分比和可读错误,不用关心签名细节。
小结
回头看,这六个坑没有一个是"网络不通",全都是本地拼出的 StringToSign 与服务端按规范重算的结果不一致:换行、排序、编码、时间、请求头、子资源。所以排查顺序建议固定为:先把规范化字符串逐行 diff → 再核对实际发出的头 → 再核对资源路径与子资源 → 最后看时钟偏移。按这个顺序走,通常十分钟内能定位;反过来从"是不是网络问题"开始猜,往往一整天就没了。
另外,签名调通只是起点。真正决定体验的是并发限流、可取消、失败不重传这几件事——用户能感知的从来不是 Authorization 头写得多漂亮,而是选了 60 张照片之后,进度条会不会卡住、点取消会不会真的停。
- 点赞
- 收藏
- 关注作者
评论(0)