把核验交给服务器能省掉一整段往返与一次独立请求;就算没有 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 到期单独监控,避免把实验当成生产依据,发布前务必复核真实握手与续取状态。

延伸阅读

来源