在开放接口的安全实践中,「时间戳 + 随机数去重 + 签名比对」的组合被广泛采用。但该组合在部分实现中仍然存在同一报文被重复接受的情况。其原因通常是三者职责被混同:只有随机数去重承担防重放职责。
一、三者的职责边界
| 手段 | 可拦截 | 不可拦截 |
| 时间戳窗口 | 超出有效期的历史报文 | 有效期内的重复报文 |
| 签名比对 | 参数被篡改 | 参数未变更的重复报文 |
| 随机数去重 | 同一报文被重复提交 | 上述两类情况 |
签名提供完整性保证,时间戳提供时效保证,仅随机数去重提供唯一性保证。一旦去重环节失效,其余两项无法替代其作用。
二、六类失效场景
1. 去重记录无淘汰机制。 记录集合持续增长,或通过定期清空控制容量——清空瞬间会使有效期内的记录整体失效。
2. 存储未集中。 多实例各自维护本地集合,同一报文在不同实例上得到不同判定结果。
3. 判定与写入非原子。 采用「先查询是否存在、再写入」的两步实现,并发场景下两个请求可同时读到空值并双双放行。
# 存在并发穿透风险的实现 if not r.exists(key): r.setex(key, ttl, "1") return True return False
4. 记录存活时间短于时间窗。 时间窗 300 秒而记录过期 60 秒时,第 200 秒的重放请求因记录已过期而被放行。
5. 随机数未纳入签名原文。 nonce 与 timestamp 仅作为参数传递时,可在保留原签名的前提下替换这两个字段重复提交。
6. 去重键缺少调用方标识。 同一调用方的测试与生产环境共用存储时,会互相干扰正常请求。
三、推荐实现
设计要点为四项:键包含时间窗编号、判定与写入原子化、记录存活时间为时间窗的 2 倍、随机数纳入签名原文。
import time import redis r = redis.Redis(host="127.0.0.1", port=6379, db=0) WINDOW = 300 # 时间窗,单位秒 TTL = WINDOW * 2 # 记录存活时间 def build_raw(params: dict, ts: int, nonce: str, secret: str) -> str: """构造签名原文,nonce 与 ts 必须参与计算。""" parts = [] for k in sorted(params): v = params[k] if v is None or v == "": continue parts.append("%s=%s" % (k, v)) parts.append("timestamp=%d" % ts) parts.append("nonce=%s" % nonce) parts.append("secret=%s" % secret) return "&".join(parts) def pass_replay_check(app_id: str, nonce: str, ts: int) -> bool: """返回 True 表示放行,False 表示判为重放。""" if abs(int(time.time()) - ts) > WINDOW: return False bucket = ts // WINDOW key = "nonce:%s:%d:%s" % (app_id, bucket, nonce) return bool(r.set(key, "1", nx=True, ex=TTL))
要点说明。 其一,set(nx=True, ex=TTL) 为单条原子命令,判定与写入在服务侧一次完成。其二,时间窗编号进入键名,使记录可按窗口整段淘汰。其三,记录存活取时间窗的 2 倍,覆盖窗口末尾的重放。其四,随机数进入签名原文后,替换随机数需重新计算签名。
四、校验顺序规范
接收请求 → 校验时间戳(超窗直接拒绝,成本最低) → 校验签名(原文含 nonce 与 timestamp) → 原子占位(去重) → 执行业务逻辑
去重须位于验签之后。若顺序倒置,未通过验签的请求亦可写入去重记录,使存储被无效数据占满,正常请求被误判为重放。
五、实施检查清单
- 去重记录设有过期时间,且长于时间窗(建议 2 倍)
- 去重存储为集中式,非各实例独立维护
- 判定与写入为单条原子命令
- 去重键包含调用方标识
nonce与timestamp参与签名原文计算- 去重环节位于验签之后
- 具备「同一报文连续提交两次,第二次被拒绝」的自动化用例
- 拦截日志可追溯命中的具体键
- 时间窗按业务类型分级设置
- 各节点时钟已校准,窗口边界返回明确错误码