2.2 临时密钥:安全感与过期时间
场景:写在客户端里的密钥,早晚是全网公开的
直传方案落地前,最大的顾虑是:密钥给客户端,它就能直接操作 COS,万一泄露怎么办?这不是杞人忧天——凡是打包进前端或客户端的任何凭据,都要当作已经泄露来对待。反编译、抓包、浏览器调试,任何一道门都挡不住有心人。一旦永久密钥泄露,对方甚至能遍历整个桶,我们的设计稿、合同、身份证扫描件全部裸奔。
所以安全模型必须换一个思路:不给客户端长期凭据,而是给一张限时、限权的通行证。客户端拿证只能做一件事(写某个对象),只能在一段时间内有效(十五分钟),过期作废。后端是唯一持有永久密钥的角色,它待在没人能访问的服务器里。
方案:通行证的三要素
权限收敛到什么程度、有效期给多久、谁来签发,是临时密钥设计的三个关键决策。先看前两者的选型:
| 维度 | 宽松方案 | 收敛方案 | 我们的选择 |
|---|---|---|---|
| 权限范围 | 桶级全权限 | 单对象级 | 单对象 PutObject |
| 有效期 | 24 小时 | 10 分钟 | 15 分钟 |
| 签发频率 | 登录时一次 | 每次上传前 | 每次上传前 |
为什么是这三个组合?单对象权限保证客户端拿到的证最多写自己那一个键,删不了别人的;十五分钟够上传大多数文件,又不足以被拿着乱用;每次上传前签发,使后端有机会做配额检查和风控拦截。这套组合的代价是后端 QPS 会随上传量上升,但签发本身极轻,几乎不耗资源。
有效期长短直接决定了安全和体验的平衡点,我们也调过好几轮:
| 有效期 | 优点 | 风险 | 结论 |
|---|---|---|---|
| 2 小时 | 大文件慢慢传不焦虑 | 泄露窗口大 | 否 |
| 30 分钟 | 折中 | 仍偏长 | 备选 |
| 15 分钟 | 泄露窗口小 | 大文件需客户端主动续期 | 采用 |
| 5 分钟 | 最安全 | 频繁换证,后端 QPS 高 | 否 |
配置:后端签发最小权限凭证
Go 侧使用 STS 接口签发,scope 明确到动作和对象前缀。注意前缀一定要精确到完整键,否则客户端可能写到别人的路径下:
// 每次上传都签发新证,scope 限定到单文件
scope := []cos.ScopeItem{{
Action: "name/cos:PutObject",
Bucket: bucket,
Region: region,
Prefix: fmt.Sprintf("uploads/%s/%s", userID, fileName),
}}
// 有效期 15 分钟,客户端必须在窗口内完成上传
cred, err := stsClient.GetCredential(ctx, scope, 900)
客户端侧的配合策略同样重要:大文件分片上传往往超过 15 分钟,不能等过期了再报错:
// 客户端启动时换证,剩余 5 分钟时自动续期
ticker := time.NewTicker(10 * time.Minute)
go func() {
for range ticker.C {
if remain() < 5*time.Minute {
token = renewToken() // 提前换新,无缝衔接
}
}
}()
下载侧同理,签名 URL 的有效期同样由后端控制,具体协议在 2.4 下载链路一节展开。
踩坑:密钥管理的四个教训
第一个坑,图省事用了永久密钥。早期的一个内部工具图方便直接内嵌 SecretKey,三个月后密钥出现在一段公开示例代码里,连夜全部轮换。从此永久密钥只存在于服务器环境变量,连我都背不下来。
第二个坑,scope 写宽了。前缀写成 uploads/ 而不是 uploads/用户 id/,任何登录用户都能覆盖别人的文件。好在没出事,是做安全审计时自己发现的,吓出一身冷汗。权限范围必须小到不能再小,宁可每次签发麻烦。
第三个坑,过期时间与客户端缓存不一致。后端发十五分钟,客户端缓存三十分钟,用到第二十分钟时请求全部 403,错误提示却是签名错误。客户端的续期时钟必须服务端下发,不能自己另算。
第四个坑,换证失败没有降级。某次 STS 接口抖动,所有新上传全部失败,老用户的进行中上传也因无法续期而中断。后来加了本地的宽限兜底:换证失败时允许使用旧证再多撑一分钟,同时触发告警,给人留出介入的窗口。
关于签发的性能,多说两句。签发接口刚上线时我们担心 QPS 压力,做了不少防护,后来发现纯属多虑:一次上传最多换两三次证,签发本身只是一次 STS 调用加一次 Redis 写入,单机轻松上万 QPS。真正要在意的是 STS 侧的配额,云厂商对每个账号的临时密钥并发数有上限,大促前最好提工单申请扩容,我们吃过一次 429 的亏。
密钥吊销是个容易被忽略的问题。临时密钥一旦签发就无法主动回收,只能等它过期。所以高危场景下我们会把有效期压到 5 分钟甚至更短,用更短的窗口替代吊销能力。如果真遇到泄露,标准流程是立刻轮换永久密钥,老的临时证会在原定期限内失效但写权限被新密钥接管,影响面可控。
再补一个权限设计上的细节:scope 里的 Action 只写 PutObject 是不够的,如果客户端要自行初始化分片上传,还需要 InitiateMultipartUpload、UploadPart、CompleteMultipartUpload 三个动作。第一次只给了一个 PutObject 结果大文件全部无法分片,报错信息指向不明确,排查了小半天。所有需要的动作要一次性列全,宁可列多再减 有效期与文件大小的关系值得单独算一算。15 分钟的证在普通家用宽带上传不完 5GB 的文件,这是事实,但不是问题。因为大文件必然走分片,分片的完成合并动作允许在证过期之后发起吗?不允许。客户端必须保证完成动作发生在证有效内,做法是续证线程提前五分钟换新证,并用新证发起 CompleteMultipartUpload。这套时序在调通之后非常稳,没有再出现过证过期导致的大文件失败。
还有一个安全细节值得强调:签名 URL 与临时密钥是两套机制,别混用。签名 URL 用的是永久密钥直接签发下载链接,安全性来自时效短;临时密钥给的是上传能力,安全性来自权限窄。下载数据本可以不经过 STS,直接用永久密钥签 URL 即可,很多人把两者耦在一起反而复杂化了权限模型。 顺带回答一个常见的疑惑:既然签发这么便宜,为什么还要在客户端做预签发缓存?因为 STS 再快也是一次网络往返,在网络差的移动端上可能就是 300 毫秒。登录时预签发一批低权限通用证存本地,常规小文件直接用,只在需要的上传前按对象范围补签。这套设计让小文件上传的首字节时间降到了 100 毫秒内,对体感的提升远大于技术上的优雅。
再强调一次日志纪律:临时密钥的签发日志要记录签发者、对象前缀、有效期,但不许记录密钥本体。我们有一次为了排查问题把完整 token 打进了日志,虽然是内部系统,仍然按事故处理了。日志是给人看的,凭据是给机器看的,两者混在一起迟早出事,所有打日志的地方都要过一遍脱敏清单。 多端登录的处理也想提一句。同一个账号在手机和电脑上同时上传,两端各自签发各自的证互不影响,这是临时密钥的优点之一:状态全在云端,客户端天然无状态。换作老的 session token 方案,多端互踢是常见烦恼。新架构下连退出登录都不用处理证,本地一抹就完事,云端那份证自生自灭。
最后交代下密钥轮换的节奏。永久密钥一季度一换,换密钥时新证立即生效、旧证十五分钟内失效,业务零感知。这件事写进日历雷打不动,毕竟再严密的权限设计也经不起一把老钥匙用三年。 补一个不容易注意到的边界:临时密钥签发接口必须做用户级频控。每个账号每分钟最多调几十次,防的是恶意刷接口囤证。虽然单证权限极窄,但批量囤证配合遍历文件名仍可能造成写放大。安全设计从来不是单点加固,是环环相扣的防线,多一层限制就少一分被批量利用的可能。 顺嘴提一句成本:STS 调用本身也是计费的,按次数收费但极便宜,我们每月签发上百万次也没心疼过。真正要防的是连环风暴式的调用,比如客户端 bug 导致一秒换证上百次,所以客户端必须做换证去重与最小间隔。
STS 的会话时长我们迭代过三次:最早两小时,用户传到一半过期被骂;改成一天,安全性又被人质疑;最后定为四小时并配合客户端无感续签。安全与体验的平衡点,永远在真实使用里才能找到。
我们还做过一次密钥泄漏演练:故意把一个 scoped 密钥贴到公开仓库,监测平台多久报警。结果第三个小时告警响起,由此确认了发现机制可靠。安全不能只靠信仰,要靠可验证的机制。
权限粒度的收紧同样是渐进过程。第一版密钥能读写整个桶,出过一次险情后全部改成按目录前缀与操作类型限定。最小权限的成本是多写几行策略,收益是出了问题爆炸半径极小。
小结
临时密钥是直传架构能成立的安全基石。限时、限权、每次签发,三条规矩把泄露风险压到可控范围。安全不是把门焊死,是让每把钥匙只能开一扇门、只能开十五分钟。
