把核验交给服务器能省掉一整段往返与一次独立请求;就算没有 Staple,客户端也仍可兜底去查。
服务器只取一次
OCSP stapling 是让「验证」这件事不拖慢握手:客户端本要通过一次额外往返,去 CA 的那台应答服务器(OCSP)查询「这张证书有没有被吊销」。开启 stapling 后,这个查询改成由服务端自己做,然后把查询结果(CA 签过名的那条)放进 TLS 握手一起发过去。浏览器就不用再单独发一次查询了,握手因此更快,也少了一层对 OCSP 提供方是否在线的依赖。
客户端免去一次查询
Staple 的机制是把「每次握手由客户端查」改成「每隔一段时间由服务端查」:服务端以缓存一份带 CA 签名的 OCSP 响应,在窗口内对所有客户端重复“贴”出同一条。每次握手几乎零成本。但 OCSP 响应本身会过期,一般以分钟或天为单位计量;太久、或被 CA 拒签、或验证不过的 staple,会让服务端退回旧路径——干脆不贴。打开的开关在 nginx 里很直白:在 nginx 里把 ssl_stapling 打开,并配置 ssl_stapling_verify 与完整证书链(中间证书)即可。
是 Staple,不是绝对保证
要摆正一个重要认识:stapling 是针对 OCSP 的优化,不是安全边界。它并没有把「吊销检查」做得比不发全程更硬,反而只是把检查挪到了服务器上并缓存。staleness 不影响握手成败——一条过期 staple 更可能让检查退回请求 CA,而不是让 TLS 失败。所以,信任应该建立在 CA 签名与整条证书链之上,而不是 stapling 本身。它真正可度量的收益是省下那一次往返,也消除了 OCSP 提供商宕机时客户端被拖慢的那一层影响。能开就开,几乎没有任何理由不开。
坏掉的样子
最常见的故障是装订响应过期却没有及时续取。服务端一直在握手里重复发送同一份缓存,一旦超过 next_update,客户端就会判定过期而丢弃,于是每次握手又退回直连证书颁发机构查询,延迟立刻回升。应当把缓存年龄与后台续取错误分开监控:续取失败时先表现为延迟上涨,而不是直接中断。续取失败往往先写错误日志,监控应当同时观察日志与装订年龄。冷启动时缓存为空,症状相同,上线前应当先预热再接受流量。
must-staple 把上述软失败变成硬失败。没有该标记时,缺失的装订只会降级为普通在线查询,可用性得以保留,但能剥离 certificate_status 扩展的中间人,就可以隐藏吊销信息。有了该标记,剥离会直接导致握手失败;代价是应答抖动、链缺失中间证书、缓存中断都会让所有握手失败。公众站点通常保留软失败,只有吊销遗漏代价远大于中断的高保障接口,才值得开启硬失败。
时钟偏斜是另一类隐蔽诱因。每份响应都带有 this_update 与 next_update,客户端时钟快了几小时,就会把新鲜装订误判为过期;时钟慢了,则可能继续信任旧装订。链不完整结果相同:装订按时到达,却因缺失中间证书无法验证,只能被丢弃。排查时先核对两端时间与下发链,再怀疑应答服务,同时保留拒绝原因与缓存时间戳以便对照。若时间与证书链都没有问题,再去检查应答服务是否限流或者更换签名密钥。
快速验证
把下面程序保存为 stapling_probe.py,运行 python3 stapling_probe.py。它只用标准库模拟服务端缓存与客户端决策,不做网络请求与验签。每份响应都包含 this_update 与 next_update,缓存只在新鲜且可验证时才贴出;客户端再按是否存在装订、是否过期、是否必须装订,决定接受、回退直查还是直接失败。
from datetime import datetime, timedelta, timezone
def make_response(this_update, lifetime_hours=48, status="good", sig_valid=True):
return {
"status": status,
"sig_valid": sig_valid,
"this_update": this_update,
"next_update": this_update + timedelta(hours=lifetime_hours),
}
class StapleCache:
"""Server-side cache: only a fresh, verifiable response may be stapled."""
def __init__(self):
self._cached = None
def store(self, response):
self._cached = response
def staple_for_handshake(self, now):
resp = self._cached
if resp is None:
return None
if not resp["sig_valid"]:
return None
if not (resp["this_update"] <= now <= resp["next_update"]):
return None
return resp
def client_decide(staple, now, must_staple=False, ocsp_reachable=True):
"""Return the client decision for one handshake."""
if staple is not None:
fresh = staple["this_update"] <= now <= staple["next_update"]
if staple["sig_valid"] and fresh:
if staple["status"] == "good":
return "accept-staple"
if staple["status"] == "revoked":
return "reject-revoked"
if must_staple:
return "hard-fail"
if ocsp_reachable:
return "soft-fail-fetch"
return "soft-fail-no-revocation"
def check(ok, message):
if not ok:
raise AssertionError(message)
def main():
now = datetime(2026, 6, 2, 12, 0, 0, tzinfo=timezone.utc)
cache = StapleCache()
check(cache.staple_for_handshake(now) is None, "empty cache must staple nothing")
check(client_decide(None, now, must_staple=False) == "soft-fail-fetch",
"missing staple without must-staple must fall back to a direct fetch")
check(client_decide(None, now, must_staple=True) == "hard-fail",
"missing staple with must-staple must fail closed")
fresh = make_response(now - timedelta(hours=1))
cache.store(fresh)
check(cache.staple_for_handshake(now) is fresh, "fresh response must be stapled")
check(client_decide(cache.staple_for_handshake(now), now) == "accept-staple",
"fresh good staple must be accepted")
expired = make_response(now - timedelta(hours=72))
cache.store(expired)
check(cache.staple_for_handshake(now) is None, "expired response must not be stapled")
check(client_decide(cache.staple_for_handshake(now), now, must_staple=False) == "soft-fail-fetch",
"expired staple must fall back to a direct fetch")
check(client_decide(None, now, must_staple=True) == "hard-fail",
"expired staple with must-staple must fail closed")
bad_sig = make_response(now - timedelta(hours=1), sig_valid=False)
cache.store(bad_sig)
check(cache.staple_for_handshake(now) is None, "unverifiable response must not be stapled")
cache.store(fresh)
skewed = now + timedelta(hours=72)
check(cache.staple_for_handshake(skewed) is None,
"a clock running far ahead must see the staple as expired")
check(client_decide(cache.staple_for_handshake(skewed), skewed, must_staple=False) == "soft-fail-fetch",
"skewed client must take the soft-fail path")
check(client_decide(None, skewed, must_staple=False, ocsp_reachable=False) == "soft-fail-no-revocation",
"stripped staple with unreachable responder leaves no revocation signal")
revoked = make_response(now - timedelta(hours=1), status="revoked")
cache.store(revoked)
check(client_decide(cache.staple_for_handshake(now), now) == "reject-revoked",
"fresh revoked staple must reject the certificate")
print("PASS: fresh accepted; expired, bad-signature, stripped, and skewed clocks fall back or fail closed as configured")
if __name__ == "__main__":
main()
程序覆盖六组断言:新鲜接受;过期拒绝并回退直查;剥离后无标记回退、有标记硬失败;签名无效拒绝贴出;时钟超前视为过期;应答不可达时标记无吊销信号。预期只有一行 PASS 结论。若断言失败,程序直接抛异常并指出环节,便于定位是过期、签名还是决策分支问题。
模型刻意简化现实:没有真实抓取颁发机构,没有验证签名,没有协商握手,也没有模拟并发续取与多机共享缓存。它只讲清缓存语义与软失败决策,线上仍需用 openssl s_client 查看真实装订,并对 next_update 到期单独监控,避免把实验当成生产依据,发布前务必复核真实握手与续取状态。
延伸阅读
来源
- RFC 6961:TLS 多证书状态请求扩展,多装订扩展与响应新鲜度规则,已核验 2026-06-02。
- Mozilla 服务器端 TLS,OCSP 装订配置与运维指导,已核验 2026-06-02。
- Cloudflare:什么是 OCSP 装订,装订响应如何省去客户端往返,已核验 2026-06-02。