Lecture 25: Security — 分布式系统安全(Security in Distributed Systems)

目录 · ← l24 · l26 →

Lecture 25: Security — 分布式系统安全(Security in Distributed Systems)

讲义对应:CS 425 FA2026 Lecture 25「Security」。本章对应课程 Lecture 27: Security(原始讲义 L27.FA25.txt,29 页):三类安全威胁(Leakage / Tampering / Vandalism)、五类常见攻击(eavesdropping / masquerading / message tampering / replay / denial of service)、CIA 性质、策略与机制的分离(policy vs. mechanism)、”Golden A’s”(Authentication / Authorization / Auditing)、设计安全系统的四步法、基本术语(principal、key、plaintext/ciphertext)、两套密码学体系(对称、公钥-私钥)、直接与间接认证、数字签名、数字证书与证书链、访问控制矩阵 / ACL / 能力表。前置内容:Lecture 8(Chord/DHT 的 finger table,用于理解 Eclipse 攻击)、时钟同步与 NTP(用于理解 Kerberos 与证书有效期)、Lecture 17(Paxos/Raft 的 quorum 与任期)、RPC 一章(at-most-once 语义与重复过滤)。 教材对应:Coulouris, Dollimore, Kindberg & Blair, Distributed Systems: Concepts and Design, 5th Ed., Ch. 11(Security):11.1 安全威胁与策略、11.2 密码学、11.3 认证与密钥分发、11.4 数字签名、11.5 证书、11.6 访问控制;补充:Ch. 2(系统模型与信任边界)、Ch. 6(间接通信与组播的成员管理)。 阅读材料:R. Needham & M. Schroeder, Using Encryption for Authentication in Large Networks of Computers, CACM 1978;L. Lamport, R. Shostak & M. Pease, The Byzantine Generals Problem, ACM TOPLAS 1982;D. Dolev & A. Yao, On the Security of Public Key Protocols, IEEE Trans. IT 1983;J. Steiner, B. C. Neuman & J. Schiller, Kerberos: An Authentication Service for Open Network Systems, USENIX 1988;RFC 4120(Kerberos V5)、RFC 8446(TLS 1.3)、RFC 6749(OAuth 2.0)/ RFC 9068(JWT 访问令牌)、RFC 6962(Certificate Transparency)、RFC 2104(HMAC);J. Saltzer & M. Schroeder, The Protection of Information in Computer Systems, 1975。

25.1 概述

本章回答的问题是:当系统没有物理边界、没有可信的中央权威、组件异构且随时随地可能被攻破时,”安全”到底如何被定义、被实现、被证明? 前面二十几章的所有算法都隐含一个乐观假设——节点是善意的:会崩溃、会变慢、会丢消息,但不会撒谎。本章第一次把这个假设撕掉:攻击者会窃听、篡改、重放、冒充、淹没你的服务端口,而被攻破的节点在故障语言里就是拜占庭故障(Byzantine failure)——它会说任何话、做任何事,且不受协议约束。

讲义给出了一个非常工程化的骨架,本章把它扩成完整的技术体系:先用三类危害(泄密 Leakage、篡改 Tampering、破坏 Vandalism)与五类攻击(窃听、伪装、消息篡改、重放、拒绝服务)描述”敌人能做什么”,再用 CIA 三性质(机密性、完整性、可用性)描述”我们要什么”,然后用 policy 与 mechanism 的分离(安全策略说”要达到什么”,安全机制说”怎么达到”)建立设计与实现的边界,最后落到四类机制:加密(encryption)、认证(authentication)、数字签名与证书(digital signature & certificate)、授权(authorization)

本章在整门课中的位置是唯一一次从”假设成立”走向”假设崩塌”:逻辑时钟、快照、Paxos、Raft 都建立在”进程不会伪造消息”之上;一旦消息可以被伪造,$3f+1$、$R+W>N$、多数派相交这些漂亮的界就要重新计算(见 25.2.16)。本章也是跨章依赖最多的一章:认证协议需要 nonce(新鲜度)或时间戳(松同步时钟),因此它把安全性直接钉在了 Lecture 11/26 的时钟同步上;DHT 的安全漏洞(Sybil、Eclipse)要回到 Lecture 8 的 Chord 环上才能看懂;而”用签名把拜占庭容错从 $3f+1$ 降到 $f+2$”这一句话,就是密码学与容错算法之间最漂亮的一笔交易

最后一个贯穿全章的判断必须一开始就立住:安全不是某个模块,而是一组假设的组合。 任何系统都可以说”我加密了”,但真正决定它安全与否的是那些没有写进代码的假设:你信任哪些 CA?你的时钟能被谁操纵?你的密钥从哪里来?被攻破之后谁来发现?本章的黄金法则(25.6)就是这句话的展开。

25.2 核心概念与分布式机制图解

25.2.1 为什么分布式系统的安全特别难(Why Distributed Security Is Hard)

  • 定义与目的:这一节先建立”难度来源”的清单。它不是修辞,而是后面每一个机制的设计理由:每一个安全机制都在针对下面五条中的某一条
  • 直观解释(”它是什么?”):单机安全像守卫一栋有围墙、有唯一大门、住客彼此认识的房子——你在边界上放一道门卫,内部就默认可信。分布式安全像守卫一座没有围墙、住客互不认识、每天还有成千上万人搬进搬出的城市:没有”内部”与”外部”之分,谁都可以声称自己住在 3 号楼,而你要判断他是不是真的住在那儿。
  • 五条根本困难
#困难具体含义导致的后果
1没有物理边界每一条消息都要穿过不可信的网络与若干中间节点(路由器、代理、负载均衡器、CDN)“内网 = 可信”的假设失效;必须端到端加密并在端点上验证
2没有可信的中央控制没有一台机器知道全局身份、全局策略、全局状态身份必须由证据(密钥、证书、票据)而非”位置”来确定
3组件异构且可能被攻破操作系统、库、容器、语言运行时版本各异,任何一个组件都可能成为缺口安全性等于最弱一环;必须按”假设会被攻破”设计
4规模巨大节点数 $10^3\sim10^7$,攻击面随接口数量线性增长密钥与身份的管理必须自动化(PKI、ACME、服务身份)
5部分失败 + 部分被攻破可能 3 台机器被攻破、2 台在重启、网络正在分区,三者同时发生安全机制必须能在部分失效下仍然提供正确判定(不能因为”联系不上 CA”就默认放行)
  • 机制图解:这张图是全章的地图——左边是攻击者能做的事,中间是被破坏的安全目标,右边是我们必须提供的机制。后面 25.2.5–25.2.15 的每一节都在填充这张图的某一格。
              攻击者能力(Threat)              目标被破坏           防御机制(Mechanism)        代表技术
        ┌───────────────────────────┐    ┌──────────────┐    ┌────────────────────┐    ┌─────────────────────┐
        │ ① 窃听 Eavesdropping      │───►│ 机密性 ✗     │───►│ 加密 Encryption    │───►│ AES-GCM、ChaCha20   │
        │    (被动、不留下痕迹)   │    │ Confidential.│    │                    │    │ TLS 1.3             │
        ├───────────────────────────┤    ├──────────────┤    ├────────────────────┤    ├─────────────────────┤
        │ ② 伪装 Masquerading       │───►│ 认证 ✗       │───►│ 认证协议 + 证书    │───►│ Kerberos、X.509     │
        │    (身份盗窃 / 冒充)    │    │ Authenticat. │    │ Authentication     │    │ OIDC、mTLS          │
        ├───────────────────────────┤    ├──────────────┤    ├────────────────────┤    ├─────────────────────┤
        │ ③ 消息篡改 Tampering      │───►│ 完整性 ✗     │───►│ MAC / 数字签名     │───►│ HMAC-SHA256、Ed25519│
        │    (改金额、改指令)     │    │ Integrity    │    │ MAC / Signature    │    │ AEAD tag            │
        ├───────────────────────────┤    ├──────────────┤    ├────────────────────┤    ├─────────────────────┤
        │ ④ 重放 Replay             │───►│ 完整性 ✗     │───►│ nonce / 时间戳 /   │───►│ Kerberos Authentic. │
        │    (旧消息重新发一遍)   │    │ + 授权 ✗     │    │ 序列号 / 重放缓存  │    │ TLS 1.3 防 0-RTT 重放│
        ├───────────────────────────┤    ├──────────────┤    ├────────────────────┤    ├─────────────────────┤
        │ ⑤ 拒绝服务 DoS            │───►│ 可用性 ✗     │───►│ 限流 / SYN cookie  │───►│ Anycast、CDN、WAF   │
        │    (耗尽资源、打满端口) │    │ Availability │    │ 配额 / 冗余        │    │ 速率限制、连接挑战  │
        └───────────────────────────┘    └──────────────┘    └────────────────────┘    └─────────────────────┘
  • 关键假设与系统模型:把上表当成假设清单来读——每引入一种机制,就要问”它依赖什么假设”(CA 可信?时钟同步?CA 的吊销信息可达?)。本章后面所有的”攻击”都是打破某个假设的结果,所有”修复”都是把假设换成更弱的假设或加上检测

25.2.2 安全目标:从三类危害到 CIA,再到”Golden A’s”

  • 定义与目的:安全目标(security objective)是可被检验的命题:”任何未授权者都无法读取这条消息”比”系统是安全的”有用得多。讲义从三类危害出发给出三个基本性质(CIA),再补上三个机制层面的目标(Authentication / Authorization / Auditing,讲义称为 “Golden A’s”),本章再补上 不可否认(Non-repudiation)
  • 直观解释:把一个在线银行系统想成一条业务流程:机密性是”信封”(别人看得到信封,看不到内容);完整性是”火漆封蜡”(拆过就能看出来);可用性是”电话任何时候都打得通”(被 DDoS 打瘫 = 服务被杀,而不是数据被偷);认证是”柜员核对身份证”;授权是”这张卡能取多少钱”;不可否认是”签了字的合同不能赖账”。
目标定义被哪种危害/攻击破坏对应机制失败的后果(举例)
机密性(Confidentiality)防止向未授权者泄露信息Leakage;窃听对称/公钥加密、TLS密码、身份证号、余额被读取
完整性(Integrity)防止未授权的修改或损坏Tampering;消息篡改MAC、数字签名、AEAD转账金额 100 被改成 10000
可用性(Availability)服务与数据始终可读可写Vandalism;拒绝服务限流、冗余、Anycast、配额大促期间下单接口被打瘫
认证(Authentication)确认通信对端是否真是它声称的身份伪装/冒充(masquerading)认证协议、证书、MFA攻击者以管理员身份登录数据库
授权(Authorization)确认”已被认证的身份”是否有权做该操作越权访问(privilege escalation)ACL、能力表、RBAC/ABAC、OAuth scope普通用户删除他人数据
不可否认(Non-repudiation)事后无法否认自己做过某个操作抵赖(repudiation)数字签名、审计日志用户否认发起过一笔转账
可审计(Auditing/Accountability)事后能重建”谁在何时做了什么”掩盖痕迹追加写日志、WORM 存储、哈希链无法定位入侵路径与影响范围
  • 关键假设与系统模型:这七个目标彼此冲突——最强的机密性(不给任何人)会杀死可用性;最强的完整性(每次都全量校验、串行化)会杀死性能;最强的不可否认(每次操作都要签名并写日志)会带来显著的延迟。因此工程上从不追求”全部拉满”,而是先写策略(policy:要达到哪些目标、在什么威胁模型下),再选机制(mechanism:怎么达到),这正是讲义强调的 policy/mechanism 分离。

25.2.3 威胁模型、攻击者模型、Dolev-Yao 与信任边界

  • 定义与目的:讲义给出的设计流程只有四步,但每一步都不可跳过:
  ① 明确攻击者模型(Attacker Model):攻击者能做什么?
        —— 攻击者模型必须与现实挂钩(tied to reality),过强则无法实现,过弱则形同虚设
  ② 按攻击者模型设计机制,使策略(policy)被满足
  ③ 证明机制在攻击者模型下确实满足策略   ← 这是"安全"与"感觉安全"的分界线
  ④ 度量机制在常态(没有攻击时)对性能的影响(吞吐量、延迟)
        —— 安全机制的代价必须在"和平时期"的指标上结算
  • 直观解释:攻击者模型就是给敌人写一份能力说明书。”他能看到网络上的所有流量,但看不懂加密内容”和”他能读心”是两种完全不同的世界:前者可以设计出 TLS,后者什么都设计不出来。写清楚这份说明书,是安全工程中最容易被跳过、也最致命的一步。
  • 攻击者能力谱系(从弱到强,右侧包含左侧):
级别攻击者能力典型现实案例能防住它的机制
L0 被动窃听只能读链路上的消息公共 WiFi 抓包加密即可
L1 主动篡改可修改、删除、注入消息恶意中间代理加密 + MAC/签名
L2 重放可原样重发截获的消息重放转账请求nonce / 时间戳 / 序列号 / 重放缓存
L3 合谋(collusion)多个被攻破节点互相配合僵尸网络、多个被攻破副本需要 $f$ 的界 + quorum(见 25.2.16)
L4 攻破节点(Byzantine)完全控制部分节点,可任意作恶被植入后门的主机拜占庭容错 + 认证
L5 攻破信任根攻破 CA、KDC、时间源、构建系统DigiNotar(2011)、SolarWinds(2020)证书透明日志、多方审计、可复现构建
  • Dolev-Yao 模型(必须掌握):1983 年 Dolev 与 Yao 提出的模型是分布式安全分析的标准攻击者模型(connection 到密码学协议的”形式化验证”传统)。它的内容可以精确表述为两条:
    1. 攻击者完全控制网络:网络就是攻击者。它可以窃听任何消息、篡改任何消息、删除/阻断任何消息、注入任意它自己构造的消息、重放任何它见过的消息、改变消息的路由与顺序,并且可以在同一时间与多个参与方并发地跑任意多个会话(concurrent sessions)。用形式化的说法:所有 $p_i \to p_j$ 的消息都经过攻击者;攻击者收到消息后可以任意选择”转发、修改、丢弃、复制”。
    2. 攻击者不能破解密码学原语:即“完美加密”假设(perfect encryption assumption)——不知道密钥就无法解密,没有私钥就无法生成合法签名,哈希不可求逆、不可碰撞,随机数不可预测。攻击者知道的只是”合法主体本该知道的公钥/公开参数”以及它自己生成的密钥。
    3. 推论(攻击者闭包):攻击者能从它已知的消息与密钥出发,用有限步做这些事——拼接(pairing)、拆分、用已知密钥加密、用已知密钥解密、生成自己的随机数/密钥、验证它见过的签名。凡是不能由这些操作导出的消息,攻击者就”得不到”。协议分析的标准做法正是:枚举攻击者的闭包,看它能否导出本应保密的东西(如会话密钥 $K_{AB}$)。
  • 信任边界(Trust Boundary)与可信计算基(TCB)
    • 信任边界是把系统切成”可信侧”与”不可信侧”的那条线。跨越这条线的每一次数据流动都必须被验证。例子:浏览器里 TLS 栈 + 操作系统根证书库在可信侧,网页 JS 与网络在不可信侧;Kerberos 中 KDC 在可信侧、客户端工作站与网络在不可信侧(这也正是 Kerberos 把票据用服务器密钥加密、而不信任客户端的原因)。
    • TCB(Trusted Computing Base)必须正确才能保证安全的那部分组件集合:操作系统内核、hypervisor、加密库、密钥存储(HSM/TPM)、根密钥、时钟源。安全问题几乎总是”TCB 比想象的大得多”:你以为只有加密库在 TCB 里,实际上编译器、依赖包、构建服务器、DNS、NTP 都在里面。
  • 机制图解:Dolev-Yao 网络与信任边界的标准画法:
            不可信侧(Untrusted)  ─────────────────────────────────────────────
 ┌──────────┐        ┌───────────────────────────────────────┐        ┌──────────┐
 │ Alice    │        │       攻击者 = 网络(Dolev-Yao)     │        │ Bob      │
 │ (客户端) │        │  窃听 / 篡改 / 删除 / 注入 / 重放 /   │        │ (服务器) │
 │          │        │  重排序 / 并发多会话 / 伪造源地址     │        │          │
 │  session │──m1───►│  ┌─────────────────────────────────┐  │──m1'──►│  session │
 │  keys    │◄─m2────│  │ 攻击者除了"破解密码学"之外         │  │◄─m2'───│  keys    │
 │          │        │  │ 什么都能做(完美加密假设)        │  │        │          │
 └────┬─────┘        │  └─────────────────────────────────┘  │        └────┬─────┘
      │              └───────────────────────────────────────┘             │
      │                                                                    │
 ─────┴──────────────────────── 信任边界(Trust Boundary)─────────────────┴──────
      可信侧(Trusted = TCB):本机内核 + 加密库 + 密钥存储 + 根证书库 + 时间源
      注意:TCB 里还悄悄包含——编译器、依赖包、构建服务器、DNS、NTP、云控制面
  • 关键假设与系统模型Dolev-Yao 是一个”下限”模型:如果连它都防不住,现实中也一定防不住。反过来,现实中攻击者常常还拥有 Dolev-Yao 之外的能力(侧信道、实现漏洞、供应链投毒),因此”在 Dolev-Yao 下被证明”只是必要条件而非充分条件——这一点在 25.2.16 会看到具体的反例(Spectre、xz 后门)。

25.2.4 五类主要威胁(Common Attacks)与它们的防御

下面五条是讲义明确列出的常见攻击,逐一给出攻击场景 → 危害 → 防御。注意一个结构性的对应关系:每类攻击破坏一个特定的安全目标,因此防御机制也是”按目标定制”的。

① 窃听(Eavesdropping)——破坏机密性

  • 场景:攻击者在共享介质(无线、交换机镜像端口、被劫持的路由器、云上的虚拟网络)上被动复制流量。它是被动的:不修改任何消息,因此很难被发现。
  • 危害:泄露口令(明文 HTTP/FTP/Telnet)、会话内容、信用卡号、隐私元数据(谁在何时与谁通信);在分布式系统内部,还可能泄露 RPC 参数、配置、token。
  • 防御加密。关键在于端到端:只在链路上加密(如”内外网之间加个 VPN”)会留下明文的内网段,而内网恰恰是攻击者横向移动的地方。现代做法是传输层用 TLS/mTLS、存储层加密、密钥放在 HSM/KMS 中,并把”元数据泄露”也纳入考量(流量分析无法靠加密消除)。

② 伪装 / 冒充(Masquerading / Spoofing)——破坏认证

  • 场景:攻击者声称自己是 Alice(伪造源 IP、伪造 DNS 应答、钓鱼站点、盗用 token、伪造 Kerberos 票据);或者在分布式系统内部,用一个未授权节点冒充某个合法副本加入集群。
  • 危害:未授权访问数据或服务(讲义的原话:identity theft);在复制系统里可伪装成主节点接受写请求(脑裂 + 数据分歧)。
  • 防御认证——使用只有真正主体才能产生的证据:共享密钥(HMAC 挑战-应答)、私钥签名(证书、mTLS)、一次性票据(Kerberos)。单靠”知道某个字符串”(口令)最弱,所以现代系统叠加 MFA。重要细节:认证必须双向(服务器也要证明自己),否则就是 MITM 的温床。

③ 消息篡改(Message Tampering)——破坏完整性

  • 场景:攻击者修改消息内容或字段顺序(把转账金额 100 改成 10000、把”只读”改成”读写”、把指令里的目标节点换掉),甚至只改动少量比特(对 CBC 模式的密文做比特翻转可造成明文的可控修改——这正是 BEAST/Lucky13 类攻击的基础)。
  • 危害:数据被静默破坏;更危险的是“看起来正常”的错误(讲义举的例子:有人改了你的银行余额)。
  • 防御MAC(消息认证码)或数字签名,且必须覆盖全部语义关键字段(包括未加密的头、长度、目标身份、协议版本)。工程上要求 AEAD(带认证的加密,如 AES-GCM、ChaCha20-Poly1305)或”先加密后 MAC”(Encrypt-then-MAC);绝不要“先 MAC 后加密”或”只加密不认证”。

④ 重放(Replay)——破坏完整性并绕过授权

  • 场景:攻击者把之前截获的完全合法的消息原样再发一次:重复的转账请求、重复的”创建用户”RPC、重放 Kerberos 服务票据、重放 TLS 1.3 的 0-RTT 早期数据。注意攻击者不需要知道消息内容——密文原样重发也能达成效果,因此加密并不能防重放。
  • 危害:重复执行副作用(重复扣款、重复下单);在认证协议中直接导致冒充成功(讲义 p20 的例子:Eve 在同一个共享密钥 $K_{AB}$ 的两次会话之间搬运 $K_{AB}(R_B)$,让 Bob 相信 Eve 是 Alice)。
  • 防御新鲜度(freshness)机制——(a) nonce(一次性随机数):挑战者每次生成新随机数,响应必须包含它,攻击者无法预测也无法复用;(b) 时间戳 + 窗口 + 重放缓存:只接受时间窗口内的消息,并记住近期见过的 $(主体, 时间戳)$(Kerberos 的做法,代价是依赖时钟同步);(c) 序列号/单调计数器:接收方只接受比已见最大值更大的序号(RPC 的”重复请求过滤”就是同一手法)。三者可以叠加,但必须有一个;”加密”永远不能替代它们。

⑤ 拒绝服务(Denial of Service, DoS)——破坏可用性

  • 场景(讲义的原话是 “bombard a port”,即打满一个端口):
    • 洪泛:SYN flood 耗尽半连接队列;UDP/ICMP 洪泛占满带宽。
    • 放大/反射(amplification/reflection):伪造受害者源地址,向大量开放解析器发小请求,让它们把大得多的响应用 UDP 打向受害者(DNS、NTP monlist、memcached 都是经典放大器,放大倍数可达数十到数万倍)。
    • 应用层慢速攻击:Slowloris 用大量”慢慢发 HTTP 头”的连接占满工作线程;同理可攻击数据库连接池、gRPC 流。
    • 算法复杂度攻击:构造让哈希表退化到 $O(n)$ 的键(HashDoS),或让正则回溯爆炸的输入。
    • 分布式 DoS(DDoS):僵尸网络、IoT 设备群。
  • 危害:合法用户无法使用服务;在分布式系统内部,DoS 还能被用作掩护(把监控/仲裁者打瘫,同时篡改数据)或触发故障转移风暴(心跳超时导致连锁重选主)。
  • 防御(分层,且必须承认”完全防住不可能”,目标是提高成本、保底可用):
    1. 网络层:Anycast 分散流量、上游清洗中心(scrubbing)、黑洞路由、SYN cookie(无状态完成三次握手)、连接限制与超时;
    2. 应用层:速率限制(rate limiting)、配额(per-tenant quota)、连接挑战(Proof-of-Work / JS challenge / CAPTCHA)、请求成本预算、限流降级;
    3. 架构层:CDN 缓存吸收流量、无状态化与自动扩容、优先级队列保证关键路径(如”读”优先于”写”)、多区域冗余;
    4. 治理层:识别业务逻辑型 DoS(例如一个昂贵查询被反复调用)并在 API 层面定价。

补充攻击(讲义之外,但分布式系统必须知道)

  • 中间人攻击(MITM, Man-in-the-Middle):攻击者同时与两端建立两个独立会话,把一端的消息解密后重新加密转发给另一端,从而既读又改,而双方都以为在直接对话。它通常不”破解”任何密码学,而是劫持密钥交换或身份绑定:伪造 DNS 应答、伪造证书、利用裸 Diffie-Hellman 的无认证特性。防御:认证密钥交换(证书绑定公钥)、证书校验、HSTS/证书固定。
  • Sybil 攻击:攻击者以极低成本伪造出大量身份(节点 ID、账户、公钥),在无许可系统中占据大量”选票”或”位置”。在 DHT 中可监控/劫持大规模查询,在 P2P 中可污染内容,在区块链中对应 51% 攻击。防御:身份成本(PoW、质押、准入控制)、基于声誉/图结构的信任、随机抽查与惩罚。
  • Eclipse 攻击:DHT 中的针对性攻击——攻击者用自己的节点包围目标节点的路由表(Chord 的 finger table)与后继链,使目标的全部查询都经过攻击者,从而完全控制目标所看到的”网络”。防御:节点 ID 的强约束(S/Kademlia 要求 ID 由公钥哈希生成并附带 PoW)、多路径/冗余路由、邻居多样化(不同 /16 网段、不同 AS)、限制路由表条目更新速率。
  • 女巫/合谋攻击与拜占庭故障:多个被攻破的节点协同撒谎,这是安全与容错理论的交汇点,见 25.2.16。这里只记住一句:安全攻击者可以比崩溃故障”更强”,因此容错所需的副本数会上升($3f+1$ 而不是 $f+1$)
  • 五类威胁到防御的映射表(考试级的速查表):
威胁破坏的目标首要防御关键前提(假设)代表技术
窃听机密性加密(端到端)密钥保密、算法公开(Kerckhoffs)AES-GCM、TLS 1.3、mTLS
伪装认证认证协议 / 证书私钥不泄露、CA 可信Kerberos、X.509、OIDC、Ed25519
消息篡改完整性MAC / 数字签名(AEAD)认证标签覆盖全部字段HMAC-SHA256、AES-GCM tag
重放完整性 + 授权nonce / 时间戳 + 缓存 / 序列号新鲜度来源可信(随机数、时钟、计数器)Kerberos Authenticator、TLS 防重放、RPC 去重
拒绝服务可用性限流 / 挑战 / 冗余分散攻击成本可被抬高、容量有余量SYN cookie、Anycast、CDN、WAF、配额

25.2.5 对称加密(Symmetric / Secret-Key Cryptography)与密钥分发问题

  • 定义与目的对称加密同一把密钥 $K_{AB}$ 完成加密与解密:$C = E(K_{AB}, M)$,$M = D(K_{AB}, C)$。它的唯一目的是提供机密性,并且以极低成本提供(现代 CPU 上 GB/s 级)。
  • 直观解释:对称加密像一把锁配两把相同的钥匙:Alice 和 Bob 各持一把,谁锁上的谁都能开。问题是”两把钥匙怎么安全地到两个人手里”——你不能把钥匙和保险箱一起寄出去。
  • 讲义给出的算法与参数(必须记住这些数值):
    • DES(Data Encryption Standard)56 位密钥,作用于 64 位分组。密钥空间只有 $2^{56}$,1998 年已被专用硬件在数天内穷举,今天绝对不可使用(注意:64 位分组中 8 位是奇偶校验位,有效密钥只有 56 位)。
    • 3DES(Triple DES):用 DES 做三次(EDE:加密-解密-加密),有效安全性约 112 位,但分组仍只有 64 位,存在 Sweet32 类碰撞风险且速度慢,属于遗留兼容方案,NIST 已于 2023 年正式弃用。
    • AES(Advanced Encryption Standard)128 / 192 / 256 位密钥128 位分组,现代标准(讲义明确写出 “state-of-the-art: AES with 256 b keys”)。AES-128 在可预见的时间内被认为是安全的,AES-256 提供更高的安全边际(也用于抵抗量子搜索类攻击的降级)。
    • ChaCha20:流密码,在没有 AES 硬件加速的移动/嵌入式设备上比 AES 更快,配合 Poly1305 组成 AEAD。
  • 工作模式(mode of operation)——这一节最容易考也最容易错
模式全称IV/Nonce并行性认证安全性要点
ECBElectronic Codebook可并行不安全,禁止使用:相同明文分组 → 相同密文分组,直接泄露数据模式(经典例子:ECB 加密的位图仍能看出轮廓)
CBCCipher Block Chaining需要随机且不可预测的 IV加密串行、解密可并行✗(需另加 MAC)链式:$C_i = E(K, M_i \oplus C_{i-1})$。IV 可预测会泄露”两段明文是否相同”,且易受填充预言攻击(POODLE、Lucky13)
CTRCounter需要每次加密唯一的 nonce完全可并行✗(需另加 MAC)把分组密码当流密码用:$C_i = M_i \oplus E(K, \text{nonce}\vert i)$。nonce 重用是灾难性的(两次密文异或 = 两次明文异或)
GCMGalois/Counter Mode96 位 nonce(推荐值)可并行✓(128 位认证标签)AEAD:一次调用同时给出机密性与完整性,是现代默认选择;同一密钥下 nonce 绝不可重复
  • 优点极快。在支持 AES-NI 的服务器 CPU 上,AES-GCM 单核吞吐可达每秒数 GB(见 25.5),比公钥运算快三到六个数量级;分组密码对任意长度数据都很自然(分组链/流模式)。
  • 缺点与”密钥分发问题(key distribution problem)”:对称加密无法在互不认识、从未共享过密钥的两方之间安全地建立密钥。更严重的是它的可扩展性:若 $N$ 个用户要两两安全通信,每人必须与其余每个人各持一把不同的密钥,总密钥数
\[K_{\text{sym}}(N) = \frac{N(N-1)}{2} = O(N^2)\]

$N=6$ 时需要 15 把;$N=1000$ 时需要 499,500 把;$N=10^6$ 时约 $5\times10^{11}$ 把——不仅存储与分发不可行,吊销更可怕:要从群里移除一个成员,必须让群里所有人换钥匙(讲义明确指出 “shared keys reveal too much information, hard to revoke permissions”)。

  • 机制图解:对称密钥数量随 $N$ 的平方增长,与公钥体系的 $N$ 对密钥形成鲜明对比(讲义 p13 明确了”每人一对公私钥”,图见 25.2.6):
       对称加密:密钥数 = N(N-1)/2                      公钥加密:密钥数 = N 对(每人一对)
       N = 6  →  15 把共享密钥                          N = 6  →  6 对密钥(6 公钥 + 6 私钥)
                                                                  ▲ 公钥全部公开,无需分发!
              A                                                A
            ╱ │ ╲                                            ┌─(pub)   (priv)─┐
          ╱   │   ╲                                          │               │
        B ────┼──── C          15 条边 = 15 把密钥           B  ─── 公开密钥目录 ─── C
        │ ╲   │   ╱ │                                        │               │
              │   ╲                                          │ ╱             │                                        D  ─── 公开密钥目录 ─── E
        D ────┼──── E                                        每对通信双方不需要任何"预共享秘密"
          ╲   │   ╱
            ╲ │ ╱                                              代价:运算慢 1000 倍以上
              F                                                 ⇒ 只用来加密"会话密钥"(见 25.2.6 混合加密)
  • 关键假设与系统模型:对称加密的保密性完全依赖密钥的保密算法公开(Kerckhoffs 原则)。它假设:(a) 密钥通过某个安全通道(离线、公钥加密、Kerberos 票据)已经共享;(b) 加密本身不提供完整性——这是初学者最致命的错误,CBC/CTR 模式的密文可以被主动篡改而产生可预测的明文变化,必须另外加 MAC(或直接用 AEAD)。

25.2.6 非对称加密(Asymmetric / Public-Key Cryptography)与混合加密

  • 定义与目的:每个主体持有一对数学相关的密钥:公钥(public key)公开,用于加密验签私钥(private key)保密,用于解密签名。它解决两个对称加密解决不了的问题:密钥分发不可否认
  • 直观解释公钥加密像街边的邮筒——谁都能把信投进去(用公钥加密),但只有邮筒的主人有一把钥匙能把信取出来(用私钥解密)。数字签名像只有你能盖的钢印(私钥盖章),任何人拿你的公开印模(公钥)都能验出这枚章是真的,但没人能仿制。
  • 讲义给出的基本等式(这两条必须能默写):
\[K_B^{priv}\big(K_B^{pub}(M)\big) = M \qquad\qquad K_A^{pub}\big(K_A^{priv}(M)\big) = M\]

第一条是加密(Alice 用 Bob 的公钥加密,只有 Bob 的私钥能解);第二条是签名(Alice 用自己的私钥”加密”,任何人用 Alice 的公钥都能验证)。讲义同时列出 RSA(Rivest–Shamir–Adleman,基于大整数分解困难)与 PGP(Pretty Good Privacy,把 RSA/DSA 与对称加密组合的一整套应用),并指出密钥长度是数百到数千比特(”keys are several 100s or 1000s of b long”)、密钥越长越难被攻破、公钥通过 PKI(Public Key Infrastructure) 维护。

  • 三种主要公钥算法及其数学基础
算法数学困难问题典型参数主要用途
RSA大整数分解($n = p q$,由 $n$ 求 $p,q$)2048–4096 位模数加密(需 OAEP 填充)、签名(需 PSS 填充)
Diffie-Hellman / ECDH有限域/椭圆曲线上的离散对数2048+ 位素数或 P-256/X25519密钥交换(不用于加密或签名,只用来协商共享秘密)
ECC(ECDSA / EdDSA)椭圆曲线离散对数256 位曲线 ≈ 3072 位 RSA 的安全性签名(ECDSA、Ed25519),密钥与签名都短得多
  • 优点:(1) 解决密钥分发:每人只持一对密钥,$N$ 个用户只需 $N$ 对($O(N)$),公钥可以像电话号码一样公开;(2) 可提供数字签名与不可否认(对称加密原理上做不到,见 25.2.8)。
  • 缺点:(1) ——同一安全强度下比对称加密慢约三个数量级,且只能处理比模数小的数据块(如 2048 位 RSA 用 PKCS#1 v1.5 填充时一次最多加密 $256-11=245$ 字节,用 OAEP-SHA-256 时只有 $256-64-2=190$ 字节),因此绝不能用来加密大消息;(2) 需要额外的填充与协议约束(裸 RSA 是确定性的,同一明文永远得到同一密文,会泄露信息且可被选择明文攻击);(3) 需要 PKI 来解决”这个公钥真的是 Bob 的吗”(见 25.2.9)。
  • 混合加密(Hybrid Encryption)——所有真实协议的做法:用公钥密码学只做一件事:安全地协商/传递一把短期对称会话密钥(session key);随后用对称 AEAD 加密全部数据。讲义明确写出了这一结论:”Many systems use public/private key system to generate shared key, and use latter on messages.”(p15)
   混合加密(TLS 1.3 的骨架):公钥只负责"把会话密钥安全送到",对称负责"搬运数据"
   ═══════════════════════════════════════════════════════════════════════════════════
   Alice(客户端)                                        Bob(服务器)
        │  ① 我需要和你安全通信,这里是我支持的算法               │
        │────────────────────────────────────────────────────►    │
        │  ② 这是我的证书(含公钥 K_Bpub)+ 我的临时 DH 公钥      │
        │◄────────────────────────────────────────────────────    │
        │  ③ 校验证书链 → 确认 K_Bpub 真的属于 Bob(见 25.2.9)   │
        │  ④ 双层密钥协商(两者都要!):                         │
        │       (a) 用 K_Bpub 加密一个随机"预主密钥"(RSA 路径)  │
        │       (b) 或用 ECDHE 现场协商共享秘密(现代路径,见下) │
        │     会话密钥 K_s = KDF(预主密钥, R_C, R_S)  ← 双方各自算出 │
        │  ⑤ Finished:用 K_s 加密的 MAC,验证握手未被篡改        │
        │◄────────────────────────────────────────────────────►   │
        │  ⑥ 之后所有应用数据都用对称 AEAD(AES-GCM)加密         │
        │◄══════════════ 对称加密:GB/s 级吞吐 ═════════════════► │
   ═══════════════════════════════════════════════════════════════════════════════════
   为什么必须混合?公钥运算 ~10^3–10^4 次/秒,对称运算 ~10^9–10^10 字节/秒(差 5–6 个数量级)
   若用 RSA 直接加密 1 GB 的数据:需要切分成数百万个 ≤190–245 字节的块 → 完全不可行
  • Diffie-Hellman 密钥交换(完整算法与数学):公开两个参数——一个大素数 $p$ 与一个生成元 $g$($g$ 是 $\mathbb{Z}_p^*$ 的生成元,阶为 $p-1$)。
    1. Alice 选一个私密随机数 $a$($1 \le a \le p-2$),计算 $A = g^{a} \bmod p$,把 $A$ 发给 Bob;
    2. Bob 选一个私密随机数 $b$,计算 $B = g^{b} \bmod p$,把 $B$ 发给 Alice;
    3. Alice 计算 $K = B^{a} \bmod p = (g^{b})^{a} = g^{ab} \bmod p$;Bob 计算 $K = A^{b} \bmod p = (g^{a})^{b} = g^{ab} \bmod p$。双方独立得到同一个 $K$
      • 为什么窃听者算不出来:攻击者只看到 $p, g, g^a, g^b$,要得到 $g^{ab}$ 需要解计算性 Diffie-Hellman(CDH)问题,其困难性归约到离散对数问题(DLP):从 $g^a \bmod p$ 求 $a$ 在 $p$ 足够大(2048 位以上)时不可行。注意:这只是一个”困难性假设”,而不是信息论意义上的不可能——若 $p$ 太小(如 $p=23$)或 $p-1$ 有小的因子(Pohlig–Hellman),攻击者可以真的把 $a$ 算出来(弱 DH 参数与 Logjam 攻击)。
      • 为什么裸 DH 无法抵抗 MITM:$g^a$ 与 $g^b$ 本身没有任何身份信息。攻击者 Mallory 完全可以对 Alice 冒充 Bob、对 Bob 冒充 Alice,建立两条独立的 DH 会话($K_{AM}$ 与 $K_{MB}$),然后双向解密-再加密转发。修复必须来自外部:把 DH 的公开值与经过认证的身份绑定——即用证书里的公钥签名 DH 参数(TLS 的 ServerKeyExchange 签名、Station-to-Station 协议、Signal 的 X3DH),或用长期公钥加密 DH 材料。这条结论是本章最重要的结论之一:密钥交换提供”共享秘密”,证书/签名提供”和谁共享”。

25.2.7 密码学哈希函数(Cryptographic Hash)与 HMAC

  • 定义与目的:哈希函数 $h = H(M)$ 把任意长度的输入映射为固定长度的短摘要(SHA-256 输出 256 位 = 64 个十六进制字符)。它被用来做完整性摘要、口令存储、MAC、数字签名的前置压缩
  • 直观解释:哈希像把一头牛绞成牛肉饼:过程不可逆(单向性),而且”牛的任何一个细节变了,牛肉饼就完全不一样”(雪崩效应)。但它不是加密——加密有钥匙、可逆;哈希没有钥匙、不可逆。
  • 四条必须能严格表述的性质
性质形式化表述攻击难度(对 $n$ 位输出)破坏它的后果
单向性 / 原像抗性(preimage resistance)给定 $y$,找到 $M$ 使 $H(M)=y$ 不可行暴力 $O(2^{n})$口令哈希可被直接还原
弱抗碰撞 / 第二原像抗性(second preimage resistance)给定 $M_1$,找到 $M_2 \ne M_1$ 使 $H(M_1)=H(M_2)$ 不可行$O(2^{n})$可替换一份已签名的文档
强抗碰撞(collision resistance)找到任意 $M_1 \ne M_2$ 使 $H(M_1)=H(M_2)$ 不可行生日攻击 $O(2^{n/2})$可伪造签名(把恶意文档与良性文档做成同一摘要再骗签)
雪崩效应(avalanche effect)输入的 1 比特变化使输出约一半比特翻转输入微小修改无法被”局部”预测
  • 算法现状(必须记准)MD5(128 位)已被攻破——2004 年王小云等人给出实用碰撞,2008 年出现可用的碰撞伪造证书(Rogue CA),2012 年 Flame 恶意软件用 MD5 碰撞伪造了微软的代码签名证书。SHA-1(160 位)已被攻破——2017 年 SHAttered 攻击(Google/CWI)实际生成了两个不同 PDF 的相同 SHA-1 摘要,成本约 $2^{63}$ 次运算(远低于暴力所需的 $2^{80}$)。现代标准:SHA-256 / SHA-512(SHA-2 家族)与 SHA-3(Keccak,抗长度扩展攻击的结构)。注意 SHA-224/384 与 SHA-512/256 等截断变体也属于 SHA-2。
  • 用途
    1. 消息摘要 / 完整性:$H(M)$ 与可信渠道得到的摘要比对(如软件下载页给出的校验和)——注意它只能检测意外损坏,不能抵抗主动攻击(攻击者可以同时替换文件和摘要),抵抗主动攻击需要 MAC 或签名。
    2. HMAC(基于哈希的消息认证码,RFC 2104):$H\big((K \oplus \text{opad}) \,\vert \, H((K \oplus \text{ipad}) \,\vert \, M)\big)$,用共享密钥 $K$ 给 $M$ 加上不可伪造的标签。HMAC 的安全性与底层哈希的抗碰撞性无关(HMAC-MD5 至今没有实用的伪造攻击),但仍应使用 HMAC-SHA-256 及以上。注意区分:$H(K \vert M)$ 这种”把密钥拼在明文前”的做法不安全(长度扩展攻击),必须用 HMAC 或将密钥放在末尾的构造。
    3. 口令存储:必须加盐(salt)+ 慢哈希。盐是每个用户独立的随机值(防彩虹表与”两个用户同口令则哈希相同”),慢哈希(PBKDF2bcryptscryptArgon2)通过大量迭代或内存硬化把单次尝试的成本抬高几个数量级。绝不能直接存明文、也不能存单轮 SHA-256 的口令哈希。
    4. 数字签名的前置压缩:先 $h = H(M)$ 再对 $h$ 签名(原因见 25.2.8)。
  • “哈希 ≠ 加密”必须讲清
    • 加密是双射且可逆的(有密钥可还原明文);哈希是多对一的压缩且不可逆(无法还原,也不需要密钥);
    • 哈希无密钥(除 HMAC 这种”带密钥的哈希”),因此哈希不能提供机密性——把口令哈希后放进数据库,攻击者拿到数据库仍能用字典离线爆破(见 25.4 代码实测);
    • 反过来,加密不提供完整性摘要的功能(除非用 AEAD),因为密文可以被篡改而”看起来正常”。

25.2.8 数字签名(Digital Signature)与 MAC 的根本区别

  • 定义与目的数字签名私钥对消息(的哈希)生成一段只有签名者能产生的证据,任何人用其公钥都能验证。讲义给的四个属性是判据:真实的(authentic)、不可伪造的(unforgeable)、可验证的(verifiable)、不可否认的(non-repudiable)
  • 直观解释:数字签名像只有你能盖的钢印:你盖上去(私钥签名),任何人拿你的公开印模一比就知道是你(公钥验签),别人没有那枚章所以仿不出来(不可伪造),而你也无法否认——因为全城只有你有那枚章(不可否认)。
  • 标准流程(先哈希再签名,必须讲清原因)
    1. 计算摘要 $h = H(M)$;
    2. 用私钥生成签名 $\sigma = \text{Sign}(K^{priv}, h)$(RSA-PSS 里是”用私钥做模幂”,ECDSA/EdDSA 里是曲线上的运算);
    3. 接收方计算 $h^{\prime} = H(M)$,验证 $\text{Verify}(K^{pub}, h^{\prime}, \sigma) \in \{\text{true}, \text{false}\}$。
      • 为什么先哈希:(a) 公钥运算只能处理比模数小的数据(2048 位 RSA 一次最多约 190–245 字节,取决于填充),且极慢——哈希把任意长度消息压缩成定长小摘要,一次签名即可;(b) 哈希的抗碰撞性保证”签了 $h$ 就等于签了 $M$”(若能找到碰撞,签名就被转移到另一份文档上,这正是 MD5/SHA-1 被攻破的杀伤力所在);(c) 签名固定长度,便于传输与存储。
  • 代表算法RSA-PSS(需填充,裸 RSA 签名不安全)、DSA(基于离散对数,签名短但已逐渐退役)、ECDSA(P-256 等曲线,密钥与签名短、速度快,是 TLS 证书的主流)、EdDSA / Ed25519(确定性签名、无随机数陷阱、速度快,现代首选)。
  • 签名不提供机密性:签名只是”附在明文旁边的一段证据”,任何人都能读消息并验证签名。要同时保密与认证,必须加密 + 签名组合(并注意 sign-then-encrypt 与 encrypt-then-sign 的安全差异——后者允许攻击者拿别人的签名密文做替换攻击,前者允许接收方伪造”发送方签名”的表象,因此实际协议通常用 AEAD + 证书而不做纯粹的 sign/encrypt 嵌套)。
  • MAC 与数字签名的对比(必考点,表格记住)
维度MAC(如 HMAC-SHA256、AEAD 认证标签)数字签名(RSA-PSS、ECDSA、Ed25519)
使用的密钥双方共享的对称密钥 $K_{AB}$签名者独有的私钥;验证用公开的公钥
谁能生成双方都能生成(任何持有 $K_{AB}$ 的人都可)只有私钥持有者能生成
不可否认性——出现争议时无法向第三方证明是谁生成的——第三方只需公钥即可判定归属
谁能验证只有持有共享密钥的双方任何人(公钥公开)
性能极快(GB/s 级,常与加密一体,AEAD 一次完成)慢(每秒 $10^3\sim10^5$ 次),有密钥交换与分发成本
密钥管理每对通信方需要一把密钥($O(N^2)$)每人一对密钥($O(N)$),但需 PKI 保证公钥真实性
典型用途会话内消息完整性(TLS 记录层、HMAC 保护的 RPC、Kafka 消息认证)证书签发、软件发布签名、电子合同、区块链交易、跨组织证据
争议场景下的结论“Alice 说是 Bob 签的,Bob 说是 Alice 签的” → 无法裁决验签成功即可归属到唯一主体
  • 一句话总结MAC 回答”这条消息有没有被改过、是不是圈内人发的”;数字签名回答”这条消息是谁发的、能否在法庭上证明”。 因此会话中数据用 MAC(便宜),而”身份声明”与”长期证据”用签名(可归责)。

25.2.9 密钥分发、中间人攻击与证书体系(PKI)

  • 定义与目的:公钥密码学把”$N^2$ 把密钥”变成”$N$ 对密钥”,但引入了一个新问题:Alice 如何确信她手里的 $K_B^{pub}$ 真的是 Bob 的公钥? 这就是密钥分发问题PKI(Public Key Infrastructure)就是用”可信第三方签名”来回答这个问题的整套基础设施。
  • 中间人攻击的完整过程(本章最重要的图):注意攻击者做的每一步都没有破解任何密码学原语——他只是替换了身份绑定
   ┌───────────────────────────────────────────────────────────────────────────────────────┐
   │  攻击前提:Alice 无法验证"这个公钥是不是 Bob 的"(无证书、无指纹核对、无预置密钥)        │
   └───────────────────────────────────────────────────────────────────────────────────────┘

   Alice(客户端)                    攻击者 Mallory                         Bob(服务器)
        │                                   │                                     │
        │ ① 请求 Bob 的公钥                 │                                     │
        │──────────────────────────────────►│  ② 拦截请求,转发给 Bob(或自己回答) │
        │                                   │────────────────────────────────────►│
        │                                   │  ③ Bob 真实公钥 K_Bpub              │
        │                                   │◄────────────────────────────────────│
        │ ④ 把 K_Bpub 换成自己的 K_Mpub     │                                     │
        │◄──────────────────────────────────│                                     │
        │   ⚠ Alice 拿到的是 K_Mpub,却以为是 Bob 的公钥                               │
        │ ⑤ 用 K_Mpub 加密会话密钥 / DH 材料│                                     │
        │──────────────────────────────────►│ 用 K_Mpriv 解密 ⇒ 得到会话密钥 K_AM │
        │                                   │ 再用 K_Bpub 加密发给 Bob ⇒ K_MB     │
        │                                   │────────────────────────────────────►│
        │                                   │◄────────────────────────────────────│
        │ ⑥ 之后:Mallory 解密所有上行消息,读取/篡改,再用另一条会话密钥重新加密下行 │
        │◄═════════════════════════════════►│◄═══════════════════════════════════►│
        │   Alice 以为在和 Bob 说话         │   Bob 以为在和 Alice 说话           │
        │   ✅ 机密性被完全破坏(攻击者读到全部明文)                                  │
        │   ✅ 完整性被完全破坏(攻击者可以任意修改,双方仍验证通过——因为标签是攻击者重算的)│
        │   ✅ 认证被完全破坏(双方都以为对端是对方)                                  │
        │   ❌ 攻击者没有破解 RSA/AES/DH 中的任何一个:他攻击的是"公钥与身份的绑定"     │
        └───────────────────────────────────────────────────────────────────────────────────────┘
   拦截时机的三种典型入口:DNS 欺骗(拿到错误 IP)→ 伪造证书;ARP/路由劫持(中间路径);
   恶意/被攻破的 CA 直接签发一张"属于 Bob"的假证书(DigiNotar,2011)
  • 证书(Certificate):证书就是“公钥 + 身份信息 + CA 的签名”三件套,标准格式X.509 v3(讲义强调 “standard format”)。其关键字段与作用:
字段内容安全作用
版本 / 序列号X.509 v3 / CA 分配的唯一编号吊销时定位具体证书
签名算法sha256WithRSAEncryptionecdsa-with-SHA256声明用哪个算法验证本证书
颁发者(Issuer)CA 的可分辨名称(DN)指向上一级证书(构成链)
有效期(Validity)notBefore / notAfter限制被攻破后的暴露窗口(依赖可信时钟!)
主体(Subject)证书持有者的 DN身份声明
主体公钥(Subject Public Key Info)算法 + 公钥比特真正被保护的对象
扩展(Extensions)SAN(域名列表)、KeyUsage、ExtendedKeyUsage、BasicConstraints(是否 CA)、CRLDistributionPoints、OCSP URL限制证书用途与范围,避免”一张证书通吃”
签名(Signature)$\text{Sign}(K_{CA}^{priv}, H(\text{证书其余全部字段}))$使证书内容不可篡改(覆盖上表所有字段)
  • 证书链与信任根(chain of trust):讲义用一个非常清楚的三级例子说明传递性(transitivity)与”回溯必须终止于受信任的根“:
    1. Alice 的银行账户证书:{Type: Account, Name: Alice, Account: 12345, CA: Charlie's Bank, Signature: K_Cpriv(Hash(Name+Account))}
    2. Charlie’s Bank 自己的公钥证书:{Type: Public Key, Name: Charlie's Bank, Public Key: K_Cpub, CA: Banker's Federation, Signature: K_Fpriv(Hash(Name+Public Key))}
    3. Banker’s Federation 的公钥证书:{Type: Public Key, Name: Banker's Federation, Public Key: K_Fpub, CA: Verisign, Signature: K_Verisign_priv(Hash(Name+Public Key))}验证时必须逐级向上验签,直到某个”预置在信任库中的根”为止——否则链条可以无限延伸(或延伸到攻击者自签的根)。
   叶子证书                    中间 CA                      根 CA                信任锚(Trust Anchor)
 ┌────────────────┐        ┌────────────────────┐      ┌───────────────────┐   ┌────────────────────────┐
 │ Subject:        │        │ Subject:            │      │ Subject:          │   │  浏览器 / 操作系统       │
 │   shop.example  │        │   Example CA R2     │      │   Example Root    │   │  预装的根证书库          │
 │ Public Key:     │        │ Public Key: K_R2    │      │ Public Key: K_root│   │  (约 100–150 个根)     │
 │   K_shop        │        │ BasicConstraints:   │      │ 自签名(Self-     │   │  ▸ 只有这些根是"无条件   │
 │ SAN: shop.ex... │        │   CA:TRUE           │      │  signed)         │   │    信任"的               │
 │ Signature:      │        │ Signature:          │      │                   │   │  ▸ 任何一条链回溯到      │
 │   Sign(K_R2,·)  │        │   Sign(K_root,·)    │      │ ┌───────────────┐ │   │    其中之一才算通过      │
 └────────────────┘        └────────────────────┘      │ │ 验证顺序:      │ │   └────────────────────────┘
        │                            ▲                    │ │ 叶子→R2→Root   │ │
        └──── 被 K_R2 签 ────────────┘                    │ └───────────────┘ │
                                                          └───────────────────┘
   客户端验证清单(缺一不可):① 签名链每一环验签通过 ② 域名/SAN 匹配 ③ 时间在有效期内
                              ④ 用途扩展允许当前用途 ⑤ 未被吊销(CRL/OCSP,或 CT 日志一致性)
  • 三种信任模型与现代化
模型结构代表优点缺点
层次式 PKI根 CA → 中间 CA → 叶子浏览器/TLS 生态、企业内网 CA可扩展、可自动化信任集中:任一 CA 被攻破 ⇒ 可伪造任意站点证书
Web of Trust用户互相签名,无中心PGP / GnuPG无单点、自治不扩展、用户负担重、无标准 UI
现代增强在层次式上叠加审计与绑定Certificate Transparency 日志(RFC 6962)、DANE/TLSA(用 DNSSEC 绑定证书)、ACME + Let’s Encrypt 自动化签发与轮换可检测错签、降低成本、缩短证书寿命CT 只”事后发现”不”事前阻止”;DANE 依赖 DNSSEC 部署
  • CA 的失败案例与信任的根本问题DigiNotar(2011)被攻破后签发了包括 *.google.com 在内的数百张伪造证书,被用于对伊朗用户的 MITM 窃听,最终导致该公司破产、荷兰政府介入;Comodo(2011)Symantec 错签(2015–2017,最终被浏览器厂商取消信任)是同类事件。结论是:层次式 PKI 把所有参与者的安全都押在少数几个 CA 的运维水平上——这是”把信任集中到 CA“的固有代价。Certificate Transparency 的设计动机正是如此:要求所有证书被记入只可追加的公开日志(append-only Merkle log)并附带 SCT(Signed Certificate Timestamp),任何域主都能监控日志、发现”别人以我的域名签了证书”。吊销(revocation)在实践中同样脆弱:CRL 文件巨大、OCSP 是”软失败”(查不到就默认放行)且泄露浏览隐私,因此业界用短有效期证书(90 天甚至 7 天)+ 自动轮换来替代吊销。
  • PKI 的完整组成(讲义之外的系统视角)注册机构 RA(核验身份:域名控制权、企业资质)→ CA(签发与吊销证书)→ 证书仓库/分发点(CRL、OCSP responder、CT 日志)→ 密钥生命周期管理(生成、备份/托管、轮换、吊销、归档)→ 策略与合规(CP/CPS 文档、审计)。企业内网还需要 私有 PKI(自建根 CA、内部 mTLS、SPIFFE/SPIRE 类工作负载身份)。

25.2.10 认证的第一种形态:共享密钥的直接认证与其重放漏洞

  • 定义与目的认证(Authentication)回答”网络上声称自己是 Alice 的这个人,真的是 Alice 吗?”讲义区分两种形态:直接认证(direct authentication)——两方直接互相证明;间接认证(indirect authentication)——借助可信第三方服务器(authentication server),如 Verisign 服务器、Kerberos 的 KDC。
  • 直观解释:共享密钥的挑战-应答像接头暗号:”天王盖地虎”(挑战)——只有真宝塔镇的人才答得上”宝塔镇河妖”(应答)。关键是暗号每次都要换(nonce),否则旁观者听一次就能冒充。
  • 讲义给出的直接认证(三消息)
   Alice(持有 K_AB)                                                  Bob(持有 K_AB)
        │                                                                    │
        │ ① A, R_A     ← R_A 是 Alice 生成的 nonce(一次性随机数)           │
        │───────────────────────────────────────────────────────────────────►│
        │ ② R_B, K_AB(R_A)   ← Bob 发新 nonce,并用共享密钥"盖章"回应 R_A    │
        │◄───────────────────────────────────────────────────────────────────│
        │   Alice 验算 K_AB(R_A) 是否等于自己发出的 R_A 的加密 ⇒ 确认对方是 Bob │
        │   ① 的 R_A 保证应答是"新鲜"的(不是重放的旧应答)                  │
        │ ③ K_AB(R_B)                                                        │
        │───────────────────────────────────────────────────────────────────►│
        │   Bob 验算 K_AB(R_B) ⇒ 确认对方是 Alice                            │
        │                                                                    │
        双向认证完成:双方各用对方的 nonce 证明"我此刻持有 K_AB"
  • 为什么不能”省掉一条消息”(讲义专门用一页讨论 “Why Not Optimize Number of Messages?”):若把协议压缩成 ① A,R_A → ② R_B,K_AB(R_A) → ③ K_AB(R_B) 之外的两消息版本(把 ② 与 ③ 合并成一条),则只有 Bob 能认证 Alice,Alice 无法认证 Bob(因为 Alice 没有向 Bob 提出过任何新鲜挑战,Bob 的应答可以来自任何一次旧会话)。结论:双向认证需要双方各自贡献一个 nonce,消息数不能随意压缩——安全性质的数量决定了消息轮数。
  • 它为什么仍然会被重放攻击打破(讲义 p20 的 Eve 攻击):协议本身对单个会话内的重放是安全的,但它没有绑定”会话边界”。攻击者 Eve 只要同时开启两条会话并搬运消息,就能让 Bob 相信她是 Alice:
      Eve(攻击者,不知道 K_AB)                       Bob                        Alice
              │                                        │                            │
   [会话 1]   │ ① A, R_B  ───────────────────────────► │  (Eve 冒充 Alice 发起)   │
              │ ② R_B2, K_AB(R_B)  ◄───────────────────│   Bob 用 K_AB 盖章 R_B     │
              │                                        │                            │
   [会话 2]   │ ③ A, R_B  ─────────────────────────────────────────────────────────► │
              │    (Eve 把 ① 里的 R_B 原样发给真正的 Alice,冒充 Bob)                 │
              │ ④ K_AB(R_B) ◄─────────────────────────────────────────────────────── │
              │    (Alice 以为是 Bob 在挑战她,于是盖章回应)                          │
              │                                        │                            │
              │ ⑤ K_AB(R_B) ─────────────────────────► │   Bob 验算通过!           │
              │    (Eve 把 ④ 的应答搬回会话 1)       │   ⇒ Bob 相信 "Alice" 已认证│
              └────────────────────────────────────────┴────────────────────────────┘
                 ①与⑤ 是"之前截获的合法消息"的原样重放 —— 加密无法阻止,只有新鲜度能阻止
  • 防御的共同结构:用挑战的新鲜度(nonce)把应答绑定到”这一次交互”;用会话标识/角色标识把消息绑定到”这个会话这个方向”(否则会出现反射攻击:把发给 A 的挑战原样发给 B 让 B 回答);用明文的身份字段防止”跨协议/跨身份搬运”(Needham-Schroeder 公钥版的著名攻击正是身份字段缺失导致的)。

25.2.11 Needham-Schroeder 对称密钥协议(1978)与 Denning-Sacco 攻击

  • 定义与目的:在 $N$ 方两两都需要通信的世界里,让每一对都预共享密钥是不可扩展的($O(N^2)$)。Needham-Schroeder 协议引入一个认证服务器 AS:每个用户只与 AS 共享一把长期密钥($K_{A,AS}$、$K_{B,AS}$),AS 负责生成并安全分发一次性会话密钥 $K_{AB}$。这直接解决了 25.2.5 的密钥分发问题,并且是 Kerberos 的直系祖先
  • 直观解释:AS 像一个发放一次性门票的售票处:Alice 找售票处要一张”能和 Bob 通话”的票,售票处给她两张条子——一张是给她自己的(用她的钥匙锁着,只有她能开),另一张是给 Bob 的票据(ticket)(用 Bob 的钥匙锁着,Alice 打不开但可以原样转交)。Bob 打开票据就知道”售票处说这个会话密钥是给 Alice 用的”。
  • 机制图解(讲义 p21 的间接认证 + 完整的五消息 NS 协议)
    Alice                    AS(认证服务器)                      Bob
   (持 K_A_AS)              (持 K_A_AS, K_B_AS)              (持 K_B_AS)
      │                            │                                │
      │ ① A, B, N_A                │                                │
      │  ("我想和 B 说话",N_A 是新随机数)                        │
      │───────────────────────────►│                                │
      │ ② E(K_A_AS, {N_A, B, K_AB, TICKET_B})                       │
      │◄───────────────────────────                                 │   其中 TICKET_B = E(K_B_AS, {K_AB, A})
      │   Alice 解出 K_AB,并用 N_A 确认这条应答是新鲜的            │
      │                            │                                │
      │ ③ TICKET_B(Alice 无法解密,只能原样转交)                  │
      │────────────────────────────────────────────────────────────►│
      │                                                             │   Bob 用 K_B_AS 解开票据,得到 K_AB 与 "A"
      │                            │   ⇒ 知道会话密钥与对端身份        │
      │ ④ E(K_AB, {N_B})           │   Bob 发新挑战 N_B               │
      │◄────────────────────────────────────────────────────────────│
      │ ⑤ E(K_AB, {N_B − 1})       │   (减 1 是为了区分"应答"与"回显")│
      │────────────────────────────────────────────────────────────►│
      │                            │   Bob 验算通过 ⇒ 相信对端是 Alice  │
      │                                                             │
   完成:双方共享 K_AB;Alice 确信对端是 B(只有 B 能解开票据并用 K_AB 答 N_B)
         Bob 确信对端是 A(只有 A 能从 AS 拿到这张票据,并回答 N_B)
  • nonce 的作用(必须讲清):$N_A$ 与 $N_B$ 是一次性随机数(number used once)。(a) $N_A$ 让 Alice 能判断”这条 AS 应答是不是针对我刚刚那次请求”,从而挡住对消息 ② 的重放(否则攻击者可以重放一条旧的 AS 应答,让 Alice 用一把攻击者已知的旧会话密钥);(b) $N_B$ 让 Bob 能判断”这条应答是不是针对我刚刚发出的挑战”,从而挡住对消息 ④/⑤ 的重放nonce 的本质是”让我方成为新鲜度的唯一来源”——攻击者无法预测它,也无法让受害者接受一个非自己生成的挑战。
  • Denning-Sacco 攻击(1981):教科书级的著名漏洞。攻击前提只有一个:某一次旧会话的密钥 $K_{AB}^{old}$ 泄露了(被破解、被日志记录、被内部人员导出、或该会话本身被盗用)。此时攻击者不需要攻破任何密码学,只需要重放旧票据
   Mallory(持有泄露的旧 K_AB_old,且记录了旧会话的第 ③ 条消息)              Bob
      │                                                                      │
      │ ③' 重放 TICKET_B_old = E(K_B_AS, {K_AB_old, A})                      │
      │─────────────────────────────────────────────────────────────────────►│
      │      Bob 用 K_B_AS 解开 —— 票据是真的、签名(加密)是真的、身份是 A  │
      │      ⚠ Bob 无法判断这是"新票据"还是"几天前的旧票据"(票据里没有新鲜度) │
      │ ④' E(K_AB_old, {N_B})                                                │
      │◄─────────────────────────────────────────────────────────────────────│
      │      Mallory 用泄露的 K_AB_old 解开,拿到 N_B                        │
      │ ⑤' E(K_AB_old, {N_B − 1})                                            │
      │─────────────────────────────────────────────────────────────────────►│
      │      Bob 验算通过 ⇒ Bob 相信他正在与 Alice 通信                      │
      │      ⚠ 而 Alice 从头到尾没有参与这次会话(她可能已经下线)           │
      └──────────────────────────────────────────────────────────────────────┘
   根因:票据里只有 {K_AB, A},没有"本次会话的新鲜度"。nonce N_A 只存在于 Alice 与 AS 之间,
         Bob 看不到 N_A,因此 Bob 对"票据是不是新鲜的"完全无判断能力。
   修复的两条路线:(a) 时间戳票据 + 时间窗口检查(Kerberos 的选择,代价:依赖时钟同步,
        见 25.2.12 与 25.4 代码 2 的实测);(b) 在 ③④⑤ 之外增加一轮"由 B 向 AS 确认"或
        让票据本身携带 Bob 的挑战(Kerberos 的 Authenticator 做到了这一点)。
  • 正确性直觉(先给出结论,严格论证见 25.3.4):NS 协议在攻击者不知道 AS 与任何用户之间长期密钥、且旧会话密钥不泄露的假设下是安全的:会话密钥只在被 $K_{A,AS}$、$K_{B,AS}$ 保护的密文里出现,因此只有 Alice 与 Bob 能得到;nonce 保证双方都确认对方此刻持有密钥。它不满足“会话密钥泄露后历史仍然安全”这一性质——恰恰相反,旧会话密钥泄露会破坏后续认证(Denning-Sacco),这也解释了为什么现代协议要求前向保密与票据新鲜度(Kerberos 用时间戳 + Authenticator 同时解决这两点)。

25.2.12 Kerberos:最广泛部署的认证协议

  • 定义与目的Kerberos V5(RFC 4120,源自 MIT Athena 项目的分布式环境需求)是 NS 协议的工程化成熟版本。它要解决一个非常具体的校园/企业问题:在一个开放网络里,几千台工作站与上百个服务之间,如何让用户”登录一次”就能安全访问所有服务,且服务器不需要为每个用户维护口令。它至今是 Windows Active Directory 的核心认证协议Hadoop/HDFS 的安全模式NFSv4(krb5 安全模式)、许多数据库与 Kafka 集群的认证基础。
  • 核心组件
    • KDC(Key Distribution Center)= AS(Authentication Server)+ TGS(Ticket Granting Server):AS 负责”验证用户身份并发 TGT”,TGS 负责”用 TGT 换具体服务的票据”。物理上通常是同一台机器上的两个逻辑服务
    • Realm(域):管理边界,如 EXAMPLE.COM;跨域访问靠 realm 之间的信任(cross-realm trust)。
    • 主体(Principal)user@REALMservice/host@REALM(如 hdfs/namenode1@EXAMPLE.COM)。
    • 票据(Ticket)E(K_{server,KDC}, {client, server, timestamp, lifetime, session key}) ——用服务器与 KDC 共享的密钥加密,因此客户端无法伪造、无法修改(讲义强调的”Alice 和 Bob 只能解开票据中属于自己的那部分”在这里被强化为”客户端根本打不开服务票据”)。
    • Authenticator(认证符)E(K_{client,server}, {client, timestamp}) ——用会话密钥加密的小结构,一次性使用,配合服务端的重放缓存(replay cache)防重放。这是 Kerberos 相对 NS 协议最关键的补强。
  • 完整六步流程(四条时间轴,必须能默画)
  Client C(持口令派生密钥 K_C)   AS(KDC 认证服务器)   TGS(票据服务器)   Server S(持 K_S)
   ①    │AS-REQ: {c, tgs, N1, 生存期}       │                     │                     │
        │──────────────────────────────────►│                     │                     │
   ②    │AS-REP: E(K_C, {K_C_TGS, N1, TGT}) │                     │                     │
        │◄──────────────────────────────────│                     │                     │
        │TGT = E(K_TGS,{c,tgs,T_start,...}) │                     │                     │
        │N1 匹配 ⇒ 这条应答是新鲜的         │                     │                     │
   ③    │TGS-REQ: TGT + Auth1               │TGS 验时间窗+缓存    │                     │
        │                                   │────────────────────►│                     │
   ④    │                                   │TGS-REP: E(K_C_TGS,  │                     │
        │                                   │  {K_C_S,N2,TKT_S})  │                     │
        │                                   │◄────────────────────│                     │
   ⑤    │                                   │                     │AP-REQ: TKT_S+Auth2  │
        │                                   │                     │────────────────────►│
   ⑥    │                                   │                     │AP-REP: E(K_CS,{T+1})│
        │                                   │                     │◄────────────────────│

(图中 TKT_S 即服务票据 TICKET_SAuth1/Auth2 即 Authenticator,TT_now;⑥ 为可选的双向认证。)

  • Authenticator 与时间戳的作用(Kerberos 的灵魂)票据本身可以被窃听者原样重放——它是一段密文,攻击者不需要读懂它就能把它发给服务器。防重放不能靠票据,只能靠与本次请求绑定的新鲜度:Authenticator 里带 T_now(由客户端时钟生成),服务器检查 $\vert T_{server} - T_{auth}\vert \le \Delta$(Kerberos 默认 $\Delta = 5$ 分钟)并把 $(client, T_{auth})$ 放进重放缓存,禁止同一时间戳重复出现。因此:
    • 重放旧的 Authenticator ⇒ 时间窗口外被拒(或缓存命中被拒);
    • 攻击者自己造新的 Authenticator ⇒ 不知道 $K_{C,S}$,无法产生可验证的密文与标签;
    • 攻击者拿到真票据也没用,因为票据的解密密钥 $K_S$ 只在 KDC 与服务器手里。 代价是 Kerberos 明确依赖”松同步的时钟”——这是一个把安全性外包给 NTP 的设计(详见 25.5 与 25.7,以及 25.2.16 第 7 条)。
  • Kerberos 的局限与注意事项(必须讲全)
    1. KDC 是单点:KDC 故障 ⇒ 全 realm 无法获得新票据(已获得的票据在有效期内仍可用,这缓解了一部分影响);工程上部署 KDC 副本,但副本之间的密钥数据库同步与主密钥(master key)保护是新挑战,且副本扩大了攻击面(KDC 是最高价值的攻击目标,因为它持有所有长期密钥的加密副本)。
    2. 时钟同步依赖:时钟被操纵(NTP 欺骗或客户端改时间)会直接影响认证——窗口过大则重放缓存放宽,窗口过小则合法请求被拒(25.4 代码 2/3 会实测这两种失败)。
    3. 口令猜测攻击:AS 的应答 E(K_C, ...)口令派生的密钥加密,而这段密文在网络上可被截获(在 Kerberos 4 与”未开启预认证(pre-auth)”的配置里尤其明显),因此可以做离线字典/暴力破解(真实世界的 “AS-REP roasting”)。这要求强口令 + 开启预认证 + 现代 KDF 迭代次数(目录服务还会有锁定策略与登录失败审计)。
    4. 票据生命周期与吊销困难:票据一旦签发,在生存期内无法被撤销(KDC 无法轻易召回);因此生存期要短(小时级),并在服务端施加额外授权检查(细粒度权限通常由服务自己决定,而不是票据)。
    5. 委派(delegation)与转发(forwarding)风险:为了让服务代用户访问后端资源(如 HDFS 中 NameNode 代表用户访问 DataNode),需要可转发票据,这扩大了票据泄露后的影响范围(现代实践用约束性委派 S4U2Proxy 与范围限制来收敛)。
  • 在现代系统中的位置:Windows Active Directory(Kerberos 是域认证的默认协议,配合 SPNEGO/Negotiate 用于 SMB、LDAP、RDP 的 SSO);Hadoop 的 Kerberos 认证kinit 获取 TGT,keytab 提供服务的长期密钥,NameNode/DataNode/RPC 之间用票据互认);NFSv4 sec=krb5/krb5i/krb5p(分别提供认证、认证+完整性、认证+完整性+加密);Kafka/SASL GSSAPI;以及跨域 SSO 的 SAML/OIDC 联合场景中常以 Kerberos 作为本地身份源。

25.2.13 公钥认证与 SSL/TLS:TLS 1.3 握手、前向保密与常见配置错误

  • 定义与目的TLS(Transport Layer Security,其前身是 SSL)是把”证书 + 密钥交换 + 对称加密 + 完整性”拼装成的一条可部署协议栈。它的三个保证是:机密性(AEAD 加密)、完整性(AEAD 认证标签)、服务器认证(证书链)——而默认不提供客户端认证(需要客户端证书 mTLS 或应用层认证,如口令/token)。
  • 直观解释:TLS 像进门前先验身份证、再换一把一次性暗号的过程:验身份证(证书)解决”你是谁”,换暗号(ECDHE 派生会话密钥)解决”后面的话怎么说才不怕别人听”,而暗号每次用完就丢(前向保密),所以即使以后身份证丢了,以前的通话录音也解不开。
  • TLS 1.3 握手(详细步骤与消息流)
   Client(客户端)                                                    Server(服务器)
      │                                                                        │
      │ ① ClientHello(明文)                                                  │
      │    · 支持的版本 / 密码套件列表(如 TLS_AES_128_GCM_SHA256)            │
      │    · 客户端随机数 R_C(32 字节)                                       │
      │    · key_share:客户端的临时 ECDHE 公钥(如 X25519)                   │
      │───────────────────────────────────────────────────────────────────►    │
      │ ② ServerHello(明文)+ 其后全部消息都被加密(TLS 1.3 的关键设计)      │
      │    · 选定密码套件、服务器随机数 R_S                                    │
      │    · key_share:服务器的临时 ECDHE 公钥                                │
      │    {EncryptedExtensions}                                               │
      │    {Certificate}        ← 服务器证书链(叶子→中间 CA→…)               │
      │    {CertificateVerify}  ← 用证书对应私钥对"握手转录哈希"签名           │
      │                            (证明持有私钥,且签名覆盖前面所有握手消息)│
      │    {Finished}           ← 用握手会话密钥计算的 MAC                     │
      │◄───────────────────────────────────────────────────────────────────    │
      │ ③ 客户端验证证书链(必须全部通过):                                   │
      │    ⓐ 每一环签名有效,且链终止于信任库中的根 CA                         │
      │    ⓑ 域名与 SAN 匹配  ⓒ 当前时间在有效期内  ⓓ 扩展允许服务器认证用途   │
      │    ⓔ 吊销检查(CRL / OCSP / OCSP stapling,实践中常"软失败")          │
      │    ⓕ 用证书里的公钥验证 CertificateVerify 的签名(证明对方持有私钥)   │
      │ ④ 双方独立计算共享秘密:ECDHE(客户端临时私钥, 服务器临时公钥) = g^{ab} │
      │    会话密钥 ← HKDF(共享秘密, R_C, R_S, 转录哈希)(密钥派生函数 KDF)   │
      │ ⑤ {Finished}:客户端用会话密钥计算并发送 MAC                           │
      │    双方比对 Finished ⇒ 确认"整段握手一个比特都没被改过"                │
      │───────────────────────────────────────────────────────────────────►    │
      │ ⑥ 应用数据:全部用对称 AEAD 加密(AES-GCM / ChaCha20-Poly1305)        │
      │◄══════════════════════════════════════════════════════════════════►    │
   ─────────────────────────────────────────────────────────────────────────────────
   握手成本:TLS 1.3 在已有 TCP 之上只需 **1 RTT**(TLS 1.2 需要 2 RTT),
             会话恢复(PSK/ticket)可以 **0-RTT** 直接带数据 —— 但有重放风险(见下)
  • TLS 提供的保证与不提供的保证(必须分清)
保证机制说明与边界
机密性ECDHE + AEAD只保护”这一跳”;TLS 在负载均衡器终止后,内部链路若是明文则等于没有端到端加密
完整性AEAD 认证标签 + Finished 的转录 MAC覆盖所有握手消息,防止握手被降级/剪裁(downgrade / truncation)
服务器认证X.509 证书链 + 信任库前提是 CA 与信任库可信;CA 被攻破则整条信任崩塌(见 25.2.9)
客户端认证❌ 默认没有需要 mTLS(客户端证书)或应用层登录(口令、OAuth/OIDC、token)
防重放1-RTT 有(依赖 R_C/R_S 新鲜度);0-RTT 没有0-RTT 的早期数据可被攻击者原样重放 ⇒ 只能用于幂等请求
不可否认❌ 没有TLS 是”会话级”证据,不等于长期可归属的签名(这是 MAC 与签名的差异)
抗流量分析❌ 基本没有加密不隐藏元数据(SNI 在 TLS 1.3 默认仍可见,除非用 ECH)
  • 前向保密(Forward Secrecy)——现代 TLS 的必备属性
    • 定义:即使服务器的长期私钥在未来被泄露,攻击者也无法解密过去已经完成的会话记录
    • 为什么 ECDHE 提供它:会话密钥来自临时(ephemeral)的 Diffie-Hellman 秘密 $g^{ab}$,其中 $a$ 由客户端生成、$b$ 由服务器生成,两者都是用完即弃的:握手结束后临时私钥被丢弃,而 $g^{ab}$ 无法从公开的 $g^a, g^b$ 反推(CDH 困难)。服务器的长期私钥只用于签名(CertificateVerify),不参与加密;泄露它只能让攻击者冒充服务器做未来的新会话,无法回溯解开历史流量
    • 反面教材(TLS 1.2 的 RSA 密钥交换):客户端用服务器证书里的长期 RSA 公钥加密预主密钥 ⇒ 会话密钥的保密性完全依赖长期私钥。攻击者只要录下流量、事后拿到私钥,就能解开全部历史会话(这正是”先收集、后解密”策略的目标)。因此 TLS 1.3 直接删除了 RSA 密钥交换,把前向保密从”建议”变成”强制”。
    • 注意 ECDHE 的两个陷阱:临时私钥必须来自高质量随机数每会话唯一(否则重复会退化为可攻击);ECDHE 只提供共享秘密,不提供身份,必须与证书/签名结合(见 25.2.6 的 MITM 分析)。
  • TLS 的常见配置错误(必须讲)
    1. 禁用证书验证:代码里写 verify=Falsessl._create_unverified_context()curl -k、把自签证书加入信任库而不做固定——这等于把 TLS 降级成”能加密但谁都能冒充”的通道,MITM 完全可行。正确做法:始终验证,用系统的信任库或明确的 CA bundle,测试环境用私有 CA 而不是关掉验证。
    2. 使用过时的协议版本或弱密码套件:SSLv3(POODLE:CBC 填充预言,可解密会话 Cookie)、TLS 1.0/1.1(BEAST:CBC IV 可预测;还有 RC4 偏差、SHA-1 签名)。正确做法:只允许 TLS 1.2+(最好 1.3),禁用 RC4/3DES/静态 RSA/CBC 套件,优先 AEAD。
    3. 降级攻击(downgrade attack):MITM 篡改 ClientHello 让双方协商到更弱的版本/套件。防御:TLS 1.3 在 ServerHello.random 的最后 8 字节里放了 DOWNGRD 哨兵,配合 Finished 覆盖完整转录,任何对握手的篡改都会让 Finished 校验失败;此外还有 ALPN/版本协商的一致性与 HSTS 防”先降级到 HTTP 再重定向”。
    4. 重协商 / 会话恢复相关攻击:TLS 1.2 的重协商可被用来注入前缀(2009 年重协商攻击),会话票据若不轮换密钥则历史流量可在票据密钥泄露后被解开。TLS 1.3 直接用 Post-Handshake Authentication 与 PSK 前向保密改进取代了旧机制
    5. 证书固定(certificate pinning)的取舍:固定(在客户端硬编码期望的 CA/公钥/SPKI 哈希)能抵抗”恶意 CA”与内网 MITM 代理,但运维代价高:证书轮换、CA 更换都会让旧客户端彻底失联(HPKP 因为”自锁死”风险已被弃用)。正确做法:用固定的”备份集合”或固定中间 CA,配合 CT 监控,并在移动端以”可远程更新的配置”下发固定信息。
    6. 只在边界做 TLS:负载均衡器终止 TLS、内部用明文 HTTP/gRPC,会让”内网 = 可信”的假设复活(横向移动即可读全部流量)。正确做法:内部同样用 TLS/mTLS 或服务网格自动 mTLS,并在应用层做端到端授权(零信任,见 25.2.15)。

25.2.14 其他认证机制:CHAP、MFA、OAuth 2.0 / OIDC、JWT、SSO

  • 口令认证与挑战-应答(CHAP, Challenge Handshake Authentication Protocol):不发送口令本身,而是发送 $H(\text{nonce} \vert \text{password})$——避免明文口令在链路上泄露,也避免”每次发送固定的口令哈希”被重放(因为它绑定 nonce)。但它要求服务器持有口令(或其等价物),因此服务器被攻破即全量泄露;且若使用弱哈希或短 nonce 仍可离线爆破。现代替代:PAKE 类协议(如 SRP)、TLS 通道内传输口令(此时安全性由 TLS 承担)。
  • 多因素认证(MFA):把”知道什么(知识)”“拥有什么(持有)”“是什么(生物特征)”三类因素组合。TOTP(RFC 6238) = $H(\text{HMAC-SHA1}(K, \lfloor T/30 \rfloor))$ 截断成 6 位数字,其中 $K$ 是注册时协商的共享密钥、$T$ 是当前 Unix 时间 ⇒ 依赖双方时钟同步(通常允许 ±1 个时间步);HOTP(RFC 4226)计数器代替时间(因此不需要时钟,但需要服务器跟踪计数器,且客户端计数器漂移会造成失步)。注意弱点:TOTP 是钓鱼可复用的(攻击者实时转发验证码即可),而基于公钥的 FIDO2/WebAuthn 把私钥绑在硬件/平台内并绑定源站(origin),是当前抗钓鱼能力最强的方案。
  • OAuth 2.0 与 OpenID Connect(现代 Web 与微服务的基础)
    • OAuth 2.0(RFC 6749)是授权框架(authorization),不是认证协议。它解决的问题是:”用户授权第三方应用代表自己去访问某个资源“,手段是发放有范围(scope)限制、有有效期的访问令牌(access token),而不是把用户口令交给第三方。
    • 四个角色:资源所有者(用户)、客户端(第三方应用)、授权服务器(发 token)、资源服务器(校验 token 并返回数据)。常见流程:授权码模式(authorization code + PKCE,Web/移动端首选)、客户端凭证模式(服务间调用)、以及已被废弃的隐式模式与密码模式。
    • OIDC(OpenID Connect)在 OAuth 之上加了一层认证:额外发放 ID Token(一个 JWT),其 subissaudexpnonce 等声明把”这次登录是谁、给哪个客户端、何时过期”说清楚。所以”用 Google 登录”是 OIDC,而”让应用访问我的 Google Drive”是 OAuth 授权。
    • 常见误解与错误:把 access token 当”身份证明”(它是授权凭证,可能是不透明的随机串,也可能恰好是 JWT);只校验签名不校验 aud/iss/exp;把 token 放在 URL 里(会进日志与 Referer);scope 过宽(scope=*);不校验重定向 URI(导致授权码被窃取)。
  • JWT(JSON Web Token, RFC 7519):结构为 base64url(header).base64url(payload).base64url(signature),即 header.payload.signature 三段。关键事实:JWT 是签名(JWS)而非加密——payload 只是 Base64 编码,任何人可读常见漏洞
    1. alg: none 攻击:把 header 的算法改成 none 并去掉签名;若库不强制白名单算法即被接受。
    2. 算法混淆(algorithm confusion):把 RS256 改成 HS256,让验证方用公开的 RSA 公钥当作 HMAC 密钥来验签——攻击者用同一公钥计算 HMAC 即可伪造 token。
    3. 不校验签名 / 只在网关校验一次:内部服务信任任何传入的 JWT;或把公钥从可控地址(jku/x5u 头指定的 URL)动态拉取。
    4. 不检查 exp / nbf / aud / iss,或允许超长有效期、不做撤销(JWT 无状态的反面就是难以撤销)。 正确做法:服务端固定允许的算法与密钥来源、严格校验全部声明、有效期尽量短并配合刷新令牌与撤销列表、敏感数据不要放进 payload。
  • SSO(单点登录)与 SAMLSSO 让用户一次登录即可访问多个服务,OAuth/OIDC 与 SAML 都是实现手段。SAML 2.0 是 XML 断言(Assertion)方案,在企业/高校联合身份(如 InCommon、教育网 Shibboleth)中广泛使用;它的著名风险类别是 XML 签名包装(XML Signature Wrapping, XSW)——攻击者把已签名的断言包在另一个未签名的断言外层,让验证方验了 A 却用了 B。经验教训与 JWT 的算法混淆完全同构:“验证的是什么”与”使用的是哪个”必须是同一个对象

25.2.15 授权与访问控制(Authorization & Access Control)

  • 定义与目的:认证解决”你是谁”,授权解决”你能做什么”。讲义给的模型是访问控制矩阵(Access Control Matrix, ACM):对每一个 (principal, object) 组合,说明允许的访问模式(read/write/execute/print/…)。它的两个现实问题讲义写得很直接:可能非常大(数千主体 × 数百万客体)且非常稀疏(绝大多数格子是”无权限”)——因此工程上从不真正存储矩阵,而是存它的两种”切片”。
  • 直观解释:把矩阵想成一张巨大的 Excel 表:行是员工,列是文件柜。存整张表既浪费又难维护,于是有两种存法——按列存(每个文件柜门上贴一张”谁能开”的名单 = ACL)或按行存(每个人口袋里揣一张”我能开哪些柜子”的卡片 = 能力表)。前者适合”东西固定、人常变”,后者适合”人固定、东西常变”。
  • 机制图解(矩阵与两种切片)
   ① 访问控制矩阵(讲义的定义:对每个 (principal, object) 说明允许的访问模式)
   ┌───────────┬──────────────┬──────────────┬──────────────┐
   │  principal│  file f1     │  file f2     │  printer p1  │
   ├───────────┼──────────────┼──────────────┼──────────────┤
   │  Alice    │ read, write  │ read         │ —            │
   │  Bob      │ read         │ —            │ print        │
   │  Carol    │ —            │ read, write  │ print        │
   └───────────┴──────────────┴──────────────┴──────────────┘
        两个现实问题:① 规模巨大(10^3 主体 × 10^6 客体)② 极度稀疏(多数格子为空)
        ⇒ 从不存矩阵,只存它的"切片"

   ② 按【客体】切片 = ACL(Access Control List):存在服务器一侧
        f1: { Alice: rw,  Bob: r }
        f2: { Alice: r,   Carol: rw }
        p1: { Bob: print, Carol: print }
        ▸ 优点:撤销简单(从名单里删一行)、与"资源"同生命周期、便于审计
        ▸ 缺点:回答"Alice 能访问什么"需要扫描所有 ACL(行查询慢);主体多了名单会长

   ③ 按【主体】切片 = Capability List(能力表):可以像证书一样"交给"客户端
        Alice: { f1: rw,  f2: r }
        Bob  : { f1: r,   p1: print }
        Carol: { f2: rw,  p1: print }
        ▸ 优点:行查询 O(1)、天然可分发(能力就是"可转让的权限凭证")、适合无中心系统
        ▸ 缺点:撤销困难(能力一旦发出,除非有有效期或可撤销句柄,否则收不回来)
        ▸ 现代对应:OAuth 的 access token 与 scope、Macaroons、UCAN、Kubernetes 的
          ServiceAccount token、文件描述符(Unix 的 fd 就是一种 capability)
  • 四种访问控制模型对比(考试速查)
模型决策依据表达能力管理成本典型场景著名性质
ACM / ACL / Capability直接的 $(主体, 客体)$ 权限表最细(逐对象逐用户)高($O(\text{主体} \times \text{客体})$)文件系统、S3 bucket policy通用基元,其他模型最终都编译到它
RBAC(基于角色的访问控制)用户 → 角色 → 权限中(角色粒度)低(用户多、角色少)企业系统、Kubernetes RBAC、数据库角色便于职责分离与最小权限,是工业界主流
ABAC(基于属性的访问控制)属性(主体/资源/环境)+ 策略语言高(可表达上下文条件)中高(策略需要治理与测试)云 IAM policy、XACML、细粒度数据访问可表达”同部门且工作时间且 MFA 已通过”这类规则
MAC / MLS(强制访问控制 / 多级安全)系统强制的安全标签(绝密/机密/秘密/非密)由标签格(lattice)决定极高(需全员打标签)政府/军事/情报系统Bell-LaPadula:no read up、no write down(保机密);Biba:no read down、no write up(保完整)
  • 模型细节与要点
    • RBAC 的层次:角色可以继承(role hierarchy),并支持约束(互斥角色:一个人不能同时是”申请人”和”审批人”——这就是职责分离 Separation of Duties)。
    • ABAC 的表达力来自”环境属性”:时间、来源 IP、设备合规状态、请求风险分。它也是零信任策略引擎的基础;代价是策略冲突与可解释性(”为什么这次请求被拒”变得难查)。
    • MAC/MLS 的两个经典模型必须记准方向Bell-LaPadula(1973,机密性)不得向上读(no read up)——低级别主体不能读高级别客体,否则泄密;不得向下写(no write down)——高级别主体不能把信息写进低级别客体,否则泄露。Biba(1977,完整性)不得向下读(no read down)——不能被低完整性数据污染;不得向上写(no write up)——低完整性主体不能污染高完整性客体。注意两者方向恰好相反(一个保机密、一个保完整),这是最经典的考点。另有 Clark-Wilson 模型强调良构事务与职责分离(商业完整性场景)。
    • 两条贯穿所有模型的原则最小权限原则(Principle of Least Privilege)——每个主体只应拥有完成任务所必需的最小权限(例:数据库连接池用只能读写特定表的账号,而不是 root);职责分离(Separation of Duties)——把关键操作拆给不同主体,使单点背叛无法完成整个流程(例:发布权限与生产审批权限分离)。
  • 分布式授权(现代系统怎么做)
    1. OAuth 的 scope:把授权范围编码进令牌,资源服务器只看令牌与 scope 即可判定,不需要回到中心数据库(可扩展性好,但撤销困难 ⇒ 短有效期 + 刷新令牌 + introspection)。
    2. 基于能力(Capability-based)的安全Macaroons(Google,2014)把权限做成可离线衰减(attenuation)的链式 HMAC 凭证——”持有者可以把权限再缩小后转交”,非常适合”把受限凭证交给第三方服务”的场景;UCAN 是基于公钥的能力令牌,用于去中心化系统。核心思想与 25.2.15 的能力表一致:权限是一个可以被传递的、自证的凭证,而不是一张需要查询的中心名单
    3. 服务网格中的 mTLS + 授权策略:Istio/Linkerd 为每个工作负载签发短期身份证书(SPIFFE ID,如 spiffe://cluster/ns/prod/sa/payments,服务间默认 mTLS,并在 sidecar 里按”源身份 + 目标服务 + 方法/路径”执行授权策略。它把”网络位置”换成”密码学身份“作为授权依据——这是零信任落地的关键技术。
    4. 零信任架构(Zero Trust,NIST SP 800-207):核心口号是 “永不信任、始终验证”(never trust, always verify)——不因”来自内网”就放行,每一次访问都要重新认证与授权,并遵循”假设已被攻破(assume breach)”做微分段与最小权限。它是对 25.2.1 第 1 条(没有物理边界)的工程回应,也是对”城堡-护城河”模型的正式否定。

25.2.16 分布式系统特有的安全问题(本讲的独特性所在)

前面 15 节讲的是”通用安全”,这一节讲只在分布式系统里才出现的问题:它们把本章与故障模型、复制、共识、成员管理、时间这些课程主线焊在了一起。

① 拜占庭故障与”用密码学买容错效率”

  • 对应关系:被攻破的节点 ≡ 拜占庭故障进程。它可能:(a) 不响应;(b) 发送矛盾的消息给不同副本(equivocation);(c) 伪造身份;(d) 与其它被攻破节点合谋
  • 副本数下界:在口头消息(oral messages,不可认证)模型下,容忍 $f$ 个拜占庭节点需要 $N \ge 3f+1$(Lamport–Shostak–Pease 1982);若消息携带不可伪造的签名(written/signed messages,SM 算法),作恶者无法伪造他人签名、也无法在转发时篡改他人消息,下界降到 $N \ge f+2$(协议用 $f+1$ 轮完成,每轮把收到的签名消息连同自己的签名继续转发)。这是“安全假设换取容错效率”的经典案例:一把签名把副本需求从 $3f+1$ 压到 $f+2$。补充说明:文献中还有”带认证时为 $2f+1$”的常见表述(以及 Dolev–Strong 协议在单发送者广播下可容忍任意 $f<N$ 的结论),数字差异来自攻击者能力假设(是否允许重放/延迟、是否只认证发送者还是认证转发链)与协议轮数;要点是带签名的下界严格低于 $3f+1$,且必须在论文里写清用的是哪一个模型
  • 工程实践PBFT(Castro–Liskov, 1999)用 $3f+1$ 副本、三阶段(pre-prepare / prepare / commit)、每请求 $O(N^2)$ 消息,把拜占庭容错做成了可部署协议(用于联盟链、许可制场景);区块链则把”身份”从”许可名单”换成”经济成本”:PoW 用算力成本、PoS 用质押成本替代身份认证,从而在无需许可(permissionless)的开放网络中抵抗 Sybil——代价是性能(吞吐/延迟)与最终性(概率确认)的退让。
  • 与本章的接口:一旦密码学假设被打破(签名可伪造、随机数可预测、哈希被碰撞),$3f+1$ 或 $f+2$ 的界全部失效:具备伪造能力的攻击者可以让任意多个副本”说任何话”。因此安全论证必须写明信任边界依赖的密码学假设

② P2P 与 DHT 的安全:Sybil 与 Eclipse(连接 Lecture 8 的 Chord)

  • Sybil 攻击:攻击者用极低成本生成大量身份,在系统中占据远超其真实资源的比例。在 DHT 中,节点 ID 决定”它负责哪些 key、位于路由表的什么位置”,因此大量假 ID 等于大量路由位置:攻击者可以监控查询、拒绝服务、或(配合内容存储)返回污染数据。在区块链中,这直接对应”51% 算力/权益”攻击;在 P2P 文件共享中对应”内容投毒”与”索引污染”。
  • Eclipse 攻击(针对单个目标的”包围”):Chord 的查找依赖finger table(指向 ID 空间上 $n+2^i$ 位置的后继)与后继链。攻击者只要让足够多的假节点恰好落在目标节点的 finger table 与后继列表的槽位上,目标的每一次查询就会先发给攻击者,攻击者再决定”如实转发、篡改应答、或让查询失败”——目标对整个 DHT 的视图被完全控制,而网络中其他节点完全正常。这是”局部攻击获得全局效果”的典型,与 25.2.4 的 MITM 在思想上同构(都是控制中间层而非破解密码学)。
   Eclipse 攻击(以 Chord 环为例,连接 Lecture 8)
   ┌──────────────────────────────────────────────────────────────────────────────────┐
   │                        ID 空间(0 ──────────── 2^m ──────────── 0)              │
   │                                                                                  │
   │        正常节点(诚实)                                被攻破/伪造的节点(攻击者)    │
   │            ●                                                ◆                    │
   │      ●           ●                    ◆        ◆                                 │
   │   ●        ┌────────────┐              ◆   ◆        ●                            │
   │      ●     │  目标节点 T │◄── 攻击者把自己的假节点精确放在 T 的 finger 槽位上,      │
   │        ●   │  (受害者)  │    再用大量假节点占据 T 的后继链                       │
   │            └────────────┘                                                        │
   │                  │                                                               │
   │                  │  T 发出的所有查询都先到达 ◆                                   │
   │                  ▼                                                               │
   │    ┌──────────────────────────────────────────────┐                              │
   │    │ 攻击者可做:① 丢弃查询(DoS)                  │                              │
   │    │            ② 返回虚假的“负责节点”列表           │                              │
   │    │            ③ 中继并篡改应答(等价于 MITM)      │                              │
   │    │            ④ 屏蔽真实节点,使 T 认为它们已下线   │                              │
   │    └──────────────────────────────────────────────┘                              │
   └──────────────────────────────────────────────────────────────────────────────────┘
   防御:① 身份成本:ID 必须由公钥哈希派生并附带 PoW(S/Kademlia)+ 准入控制
         ② 路由冗余:对同一 key 做多条不相交路径查询并取多数派(任意一条路径被控不影响结论)
         ③ 邻居多样化:路由表按不同 /16 网段、不同 AS、不同地理区域分摊槽位
         ④ 限制更新速率:防止攻击者用大量加入/离开快速"冲刷"目标的路由表
         ⑤ 声誉与抽查:对返回结果做交叉验证,发现不一致即降权/驱逐
  • 成员管理与安全未授权节点加入是最基础的攻击面——如果任何节点都能加入复制组,它就能读全量数据、投票、影响 quorum。防御:准入控制(证书/Token/预共享密钥)、成员变更走共识协议(必须由多数派批准,且新配置在被确认前不能生效),这就是 Raft 的联合共识(joint consensus)Chubby 的”用 quorum 保护成员变更”gossip 中的虚假信息(伪造”某节点已失效”以触发选主震荡)需要消息签名与成员视图的单调版本号;脑裂中的双主需要 fencing token / epoch 递增——让存储层拒绝旧主的写(fencing 是”用单调编号把旧主隔离”,与本章的”序列号防重放”是同一个思想)。
  • 分布式事务与安全2PC 的协调者一旦被攻破,它可以向部分参与者发送 commit、向另一部分发送 abort(或干脆不发第二阶段),造成部分提交与状态分歧——这在安全语言里就是协调者对参与者实施了”equivocation”,在故障语言里等价于拜占庭协调者。防御:协调者高可用(Paxos/Raft 复制协调者状态)、参与者超时后的推定决策协议(presumed abort/commit)、以及不可否认的审计日志(追加写 + 哈希链/签名,使事后无法抵赖也无法偷偷改写历史)。
  • 云与多租户安全
    • 侧信道(side channel):共享物理硬件带来信息泄露——缓存计时攻击(Prime+Probe、Flush+Reload)、Spectre/Meltdown(2018)利用推测执行跨进程/跨 VM 读取内存。防御:进程/VM 隔离加固、禁用高精度计时器、内核页表隔离(KPTI)、微码缓解、机密计算(Intel TDX / AMD SEV / Arm CCA)使 hypervisor 也无法读客户内存。
    • VM 逃逸与容器逃逸:hypervisor 或容器运行时的漏洞(如 runc CVE-2019-5736)可把租户提升到宿主机;容器共享内核 ⇒ 内核漏洞就是容器边界漏洞防御:最小化的宿主与 guest 内核、沙箱(gVisor/Kata)、Seccomp/AppArmor、及时打补丁、限制特权容器与 hostPath 挂载。
    • 共租风险(co-tenancy):与未知租户共享物理机时,性能干扰(noisy neighbor)与侧信道风险上升;专用主机/独占实例是一种(昂贵的)缓解。
    • 元数据服务 SSRF(云环境最著名的漏洞类别):云主机上的实例元数据服务(如 169.254.169.254)可返回临时凭证。若应用存在 SSRF(服务器端请求伪造),攻击者就能让服务器替自己去取 IMDS,拿到角色凭证进而读取对象存储——2019 年 Capital One 事件(约 1 亿条记录泄露)正是这条链路。防御:IMDSv2(要求 PUT 获取 token、限制跳数 hop limit=1)、出网白名单、禁止从应用发起任意 URL 请求、最小权限的实例角色。
  • 供应链安全(supply chain):现代系统的代码绝大部分不是自己写的,因此攻击者转向构建与分发链路event-stream(2018,npm 包被植入窃取钱包代码)、xz-utils 后门(CVE-2024-3094)(攻击者花两年时间成为维护者,在构建脚本里植入后门)、SolarWinds Orion(2020)(构建系统被攻破,官方签名分发的更新包带后门 ⇒ 连签名都不足以自证,因为签名者本身就是攻击面)。防御:依赖锁定与审计、SBOM(软件物料清单)、可复现构建(reproducible builds)、构建环境隔离与双人复核、制品签名与验证(Sigstore/cosign)、最小化依赖树。这一节直接回扣 25.2.3 的 TCB 讨论:编译器、构建服务器、包管理器都在 TCB 内。
  • 时间与安全的交叉(连接时钟同步一章):时间同步是安全基础设施,而不是”运维细节”:
    1. Kerberos 的认证符依赖 $\pm 5$ 分钟的时钟窗口 ⇒ 攻击者通过 NTP 欺骗(伪造 NTP 应答,或直接改客户端时间)可以让重放缓存形同虚设或让合法请求被大面积拒绝(DoS);
    2. 证书有效期校验依赖本地时钟 ⇒ 时钟被拨到证书有效期内,即可接受已过期(可能已泄露私钥)的证书;
    3. 租约(lease)与 fencing依赖时钟上界 ⇒ 时钟不准会导致两个主节点同时自认为持有租约(脑裂),这在存储系统里可能造成数据损坏;
    4. TOTP 验证码、审计日志的时间戳、事件因果排序都建立在可信时间之上。结论:NTP 本身必须被保护(认证 NTP/NTS、多源交叉校验、监控时钟漂移),并且安全机制不应把全部正确性押在时钟上(这正是 Kerberos 同时保留重放缓存、而 Kerberos 的现代替代方案更倾向挑战-应答的原因)。

25.2.17 安全工程原则(Security Engineering Principles)

这十条是本讲的”操作手册”,也是判断一个系统设计是否成熟的最快方法。前五条几乎逐字对应 Saltzer & Schroeder(1975)的经典原则,后五条是分布式系统语境下的补充。

  1. 纵深防御(Defense in Depth):不依赖任何单一防线——TLS + 认证 + 授权 + 审计 + 网络分段 + 运行时检测层层叠加。理由:任何单层都会失效(CA 会被攻破、库会有漏洞、配置会写错),多层使”单点失败”不至于变成”全盘失败”。
  2. 最小权限(Least Privilege):每个组件、服务账号、容器只拥有完成任务所需的最小权限,且默认拒绝(fail-safe defaults)理由:权限是攻击者的杠杆;被攻破的一个小服务若只有只读权限,损失就从”全库泄露”降为”读到一张表”。
  3. 失败安全(Fail Safe / Fail Secure):失败时默认拒绝而不是默认放行。反例:证书吊销检查(OCSP)在网络不可达时默认放行(soft-fail)——这等于让攻击者通过”掐断 OCSP 流量”来绕过吊销;正例:认证服务的缓存失效策略、Kubernetes admission webhook 的 failurePolicy: Fail(但这同时引入可用性风险,需要在”安全”与”可用”之间显式取舍并写出理由)。
  4. 不要自己实现密码学(Don’t Roll Your Own Crypto):用经过审计的库(OpenSSL/BoringSSL/libsodium/Go crypto)与标准协议(TLS 1.3、Noise、Signal、JOSE 的成熟配置)。理由:密码学的失败模式极其隐蔽——计时侧信道、填充预言、nonce 重用、随机数质量、MAC-then-encrypt 的顺序,任何一处都会让”看起来对”的实现变成漏洞工厂。推论也不要用自己设计的协议(”把消息加密两次就安全了”通常意味着两次都不安全)。
  5. Kerckhoffs 原则(1883)安全性应只依赖于密钥的保密,而非算法或实现的保密理由:算法会被逆向、会被泄露、会被内部人员知道;而公开算法让全世界帮忙找漏洞(AES、TLS 都是公开的)。注意:”不公开算法”最多只能算额外的(脆弱的)屏障,不能作为安全论据;但也不等于必须公开实现细节(如密钥的具体存放位置与运维流程)。
  6. 端到端原则(End-to-End Argument, Saltzer/Reed/Clark 1984):安全属性最好在端点实现,而不是依赖中间层。理由:中间层(网关、代理、服务网格、TLS 终止点)既不可靠也不完整——如果”认证”只发生在边界代理上,代理之后的每一个内部跳都是完全信任的,横向移动即可绕过;而端点可以校验端到端的签名或 MAC(例如消息在生产者签名、消费者验签,中间任何节点都无法伪造)。
  7. 假设会被攻破(Assume Breach):设计时假设部分组件已被攻破(这正是拜占庭容错的思路),因此要限制爆炸半径(微分段、按租户隔离)、保证”被攻破也能检测”(审计与完整性监控)、并保证关键决策需要多个独立主体的参与(quorum、双人审批、多方签名)。
  8. 审计与监控(Auditing & Monitoring):持续记录”谁在何时做了什么”,日志必须不可抵赖、不可静默修改(追加写、WORM、哈希链或签名、集中化收集与告警),并做异常检测。理由:检测是”假设会被攻破”的前提——如果攻破无法被察觉,纵深防御与最小权限的价值都会被时间抹平(SolarWinds 事件的关键教训之一就是”官方签名的更新也可能是攻击”)。
  9. 先建威胁模型,再选机制(Threat Modeling First):本讲 25.2.3 的四步法是这条原则的具体化。没有威胁模型的安全设计等于没有目标函数的优化:你会花大力气加密一件本来就公开的数据,却忘了攻击者真正的入口是一个未鉴权的内部管理接口。
  10. 可用的安全才是安全(Psychological Acceptability):机制必须让人愿意用、容易正确使用——否则用户会绕过它(把口令写在便签上、关掉证书验证、共享账号)。理由:安全最终是人-机系统的属性;API 的默认值(默认安全、默认开启加密、默认最小权限)比文档里的建议有效得多。

25.3 算法伪代码与正确性分析

本节给出六份伪代码,覆盖本章的全部核心机制:对称加密(AEAD)、混合加密与会话密钥协商、数字签名、Needham-Schroeder、Kerberos、TLS 1.3 握手。每份都遵循统一的四段式:假设与系统模型 → 伪代码 → 逻辑解说 → 正确性论证与复杂度。所有协议都在 Dolev-Yao 攻击者模型下分析(见 25.2.3):攻击者控制网络(可窃听、篡改、删除、注入、重放、重排、并发多会话),但不能破解密码学原语(不能解密、不能伪造签名/标签、不能求哈希原像或碰撞、不能预测随机数)。

算法 25.3.1:AES-GCM 对称加密(AEAD)

假设与系统模型

  • 双方已共享对称密钥 $K$(通过 25.3.2 的混合加密、Kerberos 或离线渠道获得),$K$ 对攻击者保密。
  • 攻击者:Dolev-Yao;可截获/篡改/重放任意密文;可选择明文(能诱导发送方加密它选定的消息,属于 IND-CPA 场景)。
  • 密码学原语假设:AES 是伪随机置换(PRP);GHASH 构造的标签不可伪造(INT-CTXT);nonce 在同一密钥下绝不重复(协议层的硬性要求)。
  • 通道:不可靠、可乱序、可重复。

伪代码

// 系统参数:密钥 K(128/192/256 bit),nonce 长度 96 bit,标签长度 128 bit
// 状态:发送方维护计数器 ctr(若用计数器型 nonce);密钥管理组件保证 nonce 唯一

procedure ENCRYPT(K, M, AAD):
    nonce ← GENERATE_NONCE(K)                  // 96 bit;随机生成或严格单调计数器
    assert NEVER_USED_BEFORE(K, nonce)          // 违反此断言 ⇒ 保密性完全丧失(见解说)
    (C, T) ← AES_GCM_SEAL(K, nonce, M, AAD)     // 一次调用同时给出密文 C 与标签 T
    return (nonce, AAD, C, T)                   // 全部字段都要传输;AAD 可为空

procedure DECRYPT(K, nonce, AAD, C, T):
    if (K, nonce) in replay_or_reuse_cache:     // 防御性检查:拒绝重复的 (K, nonce)
        return ⊥                                 // 真实系统中由上层重放缓存承担(见 25.3.4/25.3.5)
    M ← AES_GCM_OPEN(K, nonce, C, AAD, T)        // 内部先验证标签,再返回明文
    if M = ⊥:                                    // 标签验证失败
        return ⊥  and RECORD_ANOMALY()           // 绝不返回任何明文,即使只是部分
    return M

// 拆分与轮换(工程约束)
procedure KEY_LIFECYCLE(K):
    after N_max messages or T_max time:          // 例:AES-GCM 建议在同一密钥下限制密文总量
        K ← ROTATE(K)                            // 防止随机 nonce 碰撞概率累积(生日界)

算法逻辑解说(含数值小例子)

  1. 发送方为每一条消息生成一个新 nonce(例:nonce = 0x000000000000000000000001,下一条为 ...002)。计数器型 nonce 的好处是绝对不会重复,但要求状态持久化(进程重启后不能重置)。
  2. AES_GCM_SEAL 内部做两件事:用 CTR 模式把 nonce 当作计数器初值逐块加密明文得到 $C$;把 $(AAD, C)$ 一起喂给 GHASH,用 $H = \text{AES}(K, 0^{128})$ 计算多项式哈希,最后用 $\text{AES}(K, \text{nonce}\vert 0^{31}1)$ 掩码得到 128 位标签 $T$。
  3. 接收方先算标签、再做常量时间比较;若 $T$ 不匹配就整体丢弃(不能”部分解析”、不能返回错误细节——否则会形成填充/解密预言(padding oracle),历史上的 POODLE、Lucky13 类攻击都是靠”错误信息有差异”逐字节恢复明文的)。
  4. 小例子:$K$ 固定,第一条消息 M1 = "transfer 100"(nonce1, C1, T1);攻击者把 $C_1$ 的第 3 个字节翻转一比特后转发,接收方计算的标签必然不匹配(GHASH 对 $C$ 是敏感的),返回 $\bot$。攻击者无法”改一个字节而让标签仍合法”,除非它能求出 GHASH 的密钥 $H$(等价于攻破 AES)。

正确性论证(安全性质)

  • 保密性(Safety-1,机密性):在 $K$ 保密且 nonce 不重复的前提下,攻击者看到 $(nonce, C, T)$ 后无法区分 $C$ 对应的明文与任选的两个等长消息。论证思路(归约):若存在攻击者 $\mathcal{A}$ 能以不可忽略优势区分,则可构造算法 $\mathcal{B}$ 区分 AES 的输出与随机置换(或直接攻破 CTR 的 PRF 安全性)——因为 $C = M \oplus \text{keystream}(K, nonce)$,keystream 在 PRF 假设下与随机串不可区分。关键依赖:nonce 唯一性。若同一 $(K, nonce)$ 加密两条消息 $M_1, M_2$,则 $C_1 \oplus C_2 = M_1 \oplus M_2$,攻击者立即得到明文的异或(自然语言冗余足以恢复两段明文),保密性完全崩塌——这就是 GCM 最常见的致命误用。
  • 完整性(Safety-2):攻击者无法为任何未被发送方加密过的 $(nonce, AAD, C)$ 伪造出可验证的 $T$,除非它能求出 GHASH 的密钥 $H$(破 AES)或猜中 128 位标签(概率 $2^{-128}$)。因此接收方接受的消息必然逐比特等于发送方加密过的消息(并被 AAD 绑定到上下文,如”版本号 + 目标身份”)。依赖的假设是 $T$ 的比较必须是常量时间的(否则标签可被逐字节侧信道猜出),且 AAD 必须覆盖所有语义关键字段(否则”未认证的头”可被篡改,例如把协议版本降级)。
  • 活性(Liveness):对未被篡改的消息,DECRYPT 必然成功且返回原明文(AES-GCM 是确定性算法,OPEN 在标签正确时必然输出 $M$);吞吐为线性,不存在”永远解不开”的情形。注意活性边界:若密钥轮换丢失、nonce 缓存不一致(如多副本共享密钥但各自维护 nonce 计数器),会出现”合法消息被拒”的活性故障——这是设计时必须显式解决的工程问题。

复杂度

  • 时间:$O(\vert M\vert )$,单遍、可流水化;AES-NI 下吞吐量级 GB/s(见 25.5)。
  • 空间:$O(\vert M\vert )$ 密文 + 16 字节标签;流式实现只需 $O(1)$ 额外状态(除了 GHASH 的累积值)。
  • 消息/带宽开销:16 字节标签 + 12 字节 nonce ≈ 28 字节/消息(对大数据可忽略,对小消息可能占主导——这正是 IoT 上要考虑的”安全开销”)。

算法 25.3.2:混合加密(ECDHE + KDF)与会话密钥协商

假设与系统模型

  • 攻击者:Dolev-Yao,且具备并发多会话能力(可同时与 Alice、Bob 建立任意多条会话,可与同一主体并行发起多条会话)。
  • 前提:Alice 能够可靠地获得 Bob 的真实公钥(通过 PKI 证书,见算法 25.3.6);双方都有高质量随机数源(CSPRNG)。
  • 密码学假设:CDH 在所选群(曲线)上困难;$H$ 是随机预言(random oracle)以支持 KDF 的安全论证;签名不可伪造。
  • 通道:不可靠、可乱序;会话密钥用后即弃。

伪代码

// 双方公开参数:曲线/群 (G, p, g) 与哈希 H;Bob 持有长期密钥对 (sk_B, pk_B),pk_B 由 CA 签名
procedure CLIENT_SESSION_INIT():
    (a, A) ← ECDHE_KEYGEN()                 // a 临时私钥(用后丢弃), A = g^a
    R_C ← RANDOM(32)                        // 客户端随机数
    send (ClientHello, R_C, A, suites)

procedure SERVER_SESSION_INIT(ClientHello, R_C, A):
    (b, B) ← ECDHE_KEYGEN()                 // b 临时私钥(用后丢弃), B = g^b
    R_S ← RANDOM(32)
    Z ← CDH(b, A)                            // Z = A^b = g^{ab}
    send (ServerHello, R_S, B, cert_chain(pk_B), SIG(sk_B, transcript))

procedure DERIVE_KEYS(Z, R_C, R_S, transcript):
    // HKDF:先提取(extract)再扩展(expand),把"不一定均匀"的 Z 变成均匀密钥
    PRK ← HMAC_H(salt = R_C ‖ R_S, ikm = Z)
    K_hs  ← HKDF_EXPAND(PRK, info = "handshake", L = 32)   // 握手阶段密钥
    K_c2s ← HKDF_EXPAND(PRK, info = "c2s",      L = 32)   // 客户端→服务器 应用密钥
    K_s2c ← HKDF_EXPAND(PRK, info = "s2c",      L = 32)   // 服务器→客户端 应用密钥
    return (K_hs, K_c2s, K_s2c)               // 双向使用独立密钥(防止反射/密钥重用问题)

procedure CLIENT_VERIFY(ServerHello, cert_chain, sig, transcript):
    assert CERT_CHAIN_VALID(cert_chain, trusted_roots)   // 见算法 25.3.6
    assert VERIFY(pk_B, transcript, sig) = TRUE          // 证明对方持有 sk_B 且握手未被篡改
    Z ← CDH(a, B)  ;  (K_hs, K_c2s, K_s2c) ← DERIVE_KEYS(Z, R_C, R_S, transcript)
    send ENCRYPT(K_hs, "Finished", MAC(K_hs, transcript)) // 密钥确认
    DISCARD(a)                                            // 丢弃临时私钥 ⇒ 前向保密
    return (K_c2s, K_s2c)

// 应用数据传输(阶段二)
loop: send ENCRYPT(K_c2s, msg, aad = seq_no)   // AEAD,见算法 25.3.1

(RSA 密钥交换是它的等价替代路径:客户端生成 48 字节 pre_master_secret,用 RSA-OAEP(pk_B, pms) 发送,$Z$ 换成 pms但该路径不提供前向保密,TLS 1.3 已将其删除。)

算法逻辑解说

  1. Alice 生成临时密钥对 $(a, A)$,发送 $A$ 与 $R_C$;Bob 生成 $(b, B)$,返回 $B$、证书链与一个覆盖整段握手转录(transcript)的签名。
  2. Alice 先验证证书链(确认 pk_B 真的属于 Bob),再用 pk_B 验证签名(确认对方此刻持有 $sk_B$,且 $R_C, R_S, A, B$ 一个比特都没被改)。
  3. 双方各自计算 $Z = g^{ab}$:Alice 用 $B^{a}$,Bob 用 $A^{b}$。$Z$ 从未出现在网络上,攻击者即使记录了 $A$、$B$,也只能面对 CDH 问题。
  4. KDF 不是可选项:DH 的共享秘密是群元素而非均匀比特串(有结构、有偏差),必须用 HKDF(HMAC 为底的提取-扩展)把它”提纯”成密钥;同时把 $R_C, R_S$ 与转录哈希作为输入,使双方派生出相同的密钥密钥与本次握手绑定(任何消息被改动都会导致密钥不一致 ⇒ Finished 校验失败)。
  5. 小例子(教学素数 $p=23, g=5$):$a=6 \Rightarrow A = 5^6 \bmod 23 = 8$;$b=15 \Rightarrow B = 5^{15} \bmod 23 = 19$;共享秘密 $Z = 19^6 \bmod 23 = 8^{15} \bmod 23 = 2$。攻击者只看到 $(5, 23, 8, 19)$,要得到 $2$ 需要解离散对数(在 2048 位群或 256 位曲线上不可行;在 $p=23$ 上可以暴力枚举——这正是 25.4 代码里”教学版不安全”的含义)。

正确性论证(安全性质)

  • 会话密钥保密性(Safety-1):假设攻击者能得到 $K_{c2s}$,则它要么猜到 $Z$(需要解 CDH,被假设排除),要么伪造/绕过证书与签名从而进入”公式里的 Bob”这一角色(需要在算法 25.3.6 的假设下攻破 PKI,被假设排除),要么攻破 KDF/HMAC(被随机预言假设排除)。三条路都封死 ⇒ 密钥只有 Alice 与 Bob 能算出。
  • 对端认证(Safety-2):Alice 接受会话,意味着她验证了 SIG(sk_B, transcript),即对端持有 $sk_B$;而由证书链的有效性(算法 25.3.6)可推出 $pk_B$ 确实绑定到身份 “Bob”。因此若对端能让 Alice 完成握手,则对端就是 Bob(在 CA 可信的假设下)。
  • 完整性/防篡改(Safety-3):签名与 Finished 都覆盖转录,任何对 $A, B, R_C, R_S$、套件列表、证书链的修改都会导致签名验证失败或 Finished 不匹配 ⇒ 攻击者无法实施降级攻击(把双方骗到弱算法上)。
  • 前向保密(Safety-4,条件性质):设攻击者在会话结束后获得了 $sk_B$。由于会话密钥只依赖 $Z = g^{ab}$,而 $a, b$ 已被丢弃,且从公开的 $(g^a, g^b)$ 求 $g^{ab}$ 是 CDH(困难),故攻击者无法解密已完成的会话记录。$sk_B$ 的泄露只能让它冒充 Bob 参与未来的新会话(这属于密钥撤销问题,需要证书吊销/轮换来限制窗口)。注意这个论证的边界:若临时私钥随机数质量差(可预测)、或临时密钥被复用、或实现把 $a$ 保存在可读的进程内存/日志里,前向保密即刻失效。
  • MITM 的成立条件(Safety-5,反面论证):把上述伪代码里的 CLIENT_VERIFY 中”证书链校验 + 签名验证”删除(即裸 DH),则攻击者可以自己选 $(m_1, M_1)$ 与 Alice 协商 $Z_1 = g^{a m_1}$、选 $(m_2, M_2)$ 与 Bob 协商 $Z_2 = g^{b m_2}$,并双向解密-再加密转发:Alice 与 Bob 谁都不会察觉,因为裸 DH 的输入中没有任何身份信息。因此“密钥交换提供共享秘密,认证绑定提供对端身份”——两者缺一不可。
  • 活性(Liveness):双方各做 $O(1)$ 次群运算与哈希,握手在一个 RTT 内完成(有 TCP 时共 2 RTT);只要双方诚实且网络最终交付,握手必然终止并协商出相同密钥(DH 的交换律保证 $(g^a)^b = (g^b)^a$ 恒成立)。

复杂度

  • 时间:客户端/服务器各约 $O(\log p)$ 次模乘(或一次曲线标量乘)+ $O(1)$ 次 HMAC;X25519 单核约 $10^4$ 量级次/秒(见 25.5 表)。
  • 空间:$O(1)$ 密钥材料(数百字节);临时私钥必须在使用后立即从内存擦除(否则前向保密在实现层面失效)。
  • 消息/带宽:1 RTT;公钥材料约 32–64 字节(X25519/P-256),证书链通常 1–4 KB,是本阶段最大的流量。

算法 25.3.3:数字签名与验证(Sign-then-Verify)

假设与系统模型

  • 签名者持有私钥 $sk$,验证者持有经过认证的公钥 $pk$(来自证书或预置信任锚);$sk$ 未泄露。
  • 攻击者:EUF-CMA(existential unforgeability under chosen-message attack)——它可以让签名者对任意它选择的消息签名(现实中攻击者可能诱导你签一份文件),但仍不能对任何它没问过的新消息生成有效签名。
  • 密码学假设:RSA-PSS 的不可伪造性(或曲线上的离散对数困难);哈希 $H$ 抗碰撞(否则可把签名转移到另一份文档上,见 MD5/SHA-1 的教训);签名所需随机数不可预测(ECDSA 的 $k$ 重用会直接泄露私钥——Sony PS3 与大量比特币钱包被盗都源于此,因此现代优选确定性的 Ed25519)。

伪代码

// 密钥生成(以 RSA 为例,教学规模;真实系统用 2048–4096 bit + OAEP/PSS 填充)
procedure KEYGEN(bits):
    repeat: p ← RANDOM_PRIME(bits/2); q ← RANDOM_PRIME(bits/2) until p ≠ q
    n ← p·q ;  φ ← (p−1)(q−1)
    e ← 65537 ;  assert gcd(e, φ) = 1
    d ← MODINVERSE(e, φ)                       // d·e ≡ 1 (mod φ)
    return (pk = (n, e), sk = (n, d))

// 签名:先哈希再签名(Hash-then-Sign)
procedure SIGN(sk, M):
    h ← H(DOMAIN ‖ M)                          // DOMAIN = 协议/用途标识,防跨协议重放
    σ ← RSA_PSS_SIGN(sk, h)                    // = h^d mod n(带盐的 PSS 编码,非裸 RSA)
    return σ                                   // 典型长度:RSA-2048 → 256 B;Ed25519 → 64 B

// 验证
procedure VERIFY(pk, M, σ):
    h ← H(DOMAIN ‖ M)
    return RSA_PSS_VERIFY(pk, h, σ)            // = (σ^e mod n 是否等于 PSS 编码(h));e=65537 极快

// 使用者的完整检查(签名的"可用性"取决于这一步,而不是算法本身)
procedure ACCEPT_MESSAGE(M, σ, cert_signer):
    assert CERT_CHAIN_VALID(cert_signer) = TRUE   // pk 真的属于声称的签名者
    assert CERT_NOT_REVOKED(cert_signer) = TRUE
    assert VERIFY(cert_signer.pk, M, σ) = TRUE
    // ⚠ 注意:签名不提供"新鲜度"。重放一个旧的合法 (M, σ) 仍然会通过验证!
    //    ⇒ 若需要防重放,必须叠加 nonce / 时间戳 / 序列号(见 25.3.4、25.3.5)
    ACCEPT(M)

算法逻辑解说(含数值小例子)

  1. 取 $p=61, q=53 \Rightarrow n = 3233$,$\varphi = 60 \times 52 = 3120$;$e = 17$($\gcd(17,3120)=1$);$d = 17^{-1} \bmod 3120 = 2753$(因为 $17 \times 2753 = 46801 = 15\times3120 + 1$)。这套参数只用于教学:$n$ 只有 12 位,几毫秒就能分解(见 25.4 代码 1)。
  2. 消息 $M = \text{“transfer 100 to Bob”}$,$h = H(M)$ 得到一个整数(真实系统是 256 位摘要)。
  3. 签名 $\sigma = h^{d} \bmod n$;验证者计算 $\sigma^{e} \bmod n$ 并检验它是否等于 $h$(真实系统还要检验 PSS 编码中的填充结构与盐)。
  4. 篡改 $M$ 的一个字符(把 100 改成 900)⇒ $h^{\prime} \ne h$ ⇒ 验证失败。伪造签名需要计算 $h^{d} \bmod n$,即求 $e$ 次根,其困难性等价于分解 $n$。

正确性论证(安全性质)

  • 不可伪造性(Safety-1):假设存在 EUF-CMA 攻击者 $\mathcal{A}$ 能以不可忽略概率对新消息 $M^$ 产出有效 $(\sigma^, M^)$。由于 $\sigma^$ 必须满足 $\sigma^{e} \equiv H(M^) \pmod n$,$\mathcal{A}$ 实际上解出了模 $n$ 的 $e$ 次根问题;而 RSA 假设正是该问题在不知 $n$ 的分解时困难。若 $M^$ 是它查询过的消息,则要求同一 $h$ 有第二个有效签名(PSS 的随机盐使这最多是概率性的),或要求它找到了哈希碰撞($h(M^) = h(\tilde M)$ 而 $M^* \ne \tilde M$)——两者都被假设排除。两个依赖必须写明:哈希抗碰撞 + 私钥未泄露。
  • 完整性与认证(Safety-2):验证通过 ⇒ 消息自签名以来未被修改(任何修改都会改变 $h$),且签名者持有 $sk$但注意:这只证明”这条消息曾被 $sk$ 持有者签过”,不证明”这是刚刚发生的”——签名是”证据”,不是”新鲜度来源”。
  • 不可否认性(Safety-3,与 MAC 的分水岭):设 Alice 与 Bob 就某条消息发生争议,且双方都信任 CA(即 $pk_A$ 确实属于 Alice)。仲裁者只需公钥即可验证:若 $\text{Verify}(pk_A, M, \sigma) = $ true,则由 Safety-1(不可伪造)可知只有 $sk_A$ 的持有者能产出 $\sigma$;在”$sk_A$ 未泄露、未共享”的假设下,产出者只能是 Alice ⇒ 无法抵赖。这正是 MAC 无法提供的能力:MAC 的标签任何持有共享密钥的人都能生成,仲裁者无从判断是 Alice 还是 Bob 造的 ⇒ 无法裁决(25.4 代码 1 的第 7 节会把这场景实际跑出来)。代价/边界:不可否认性完全取决于”私钥归属”这一法律与技术事实——若私钥由公司持有、或存储在可被内部人员导出的软件密钥库中,”不可否认”在法庭上往往站不住脚(因此高保证场景用 HSM、智能卡、硬件令牌做”持有者不可导出”的密钥)。
  • 活性(Liveness):签名与验证都是确定性算法,对有效签名必然通过(除了证书过期/吊销这类策略性拒绝——它们属于授权层而非密码学层,必须与”签名无效”区分开,否则会误导运维)。
  • 已知的实现陷阱(正确性在实现层面被打破的典型):ECDSA 重用 $k$(直接泄露私钥);RSA 用裸 $h^d$ 而不加 PSS(存在可乘性伪造);签名前不做域分隔(同一密钥签”转账 100”与签”投票 100”可能产生相同输入,导致跨协议签名重用);验证时用字符串比较而不是常量时间比较(时序侧信道);只验证签名不验证证书链(把攻击者自签的证书当成合法公钥)。

复杂度

  • 时间:签名 $O(\log^3 n)$(模幂,指数为 $d$,2048 位下约 $10^3$ 次/秒/核);验证因为 $e=65537$ 只有两个 1 比特而快约 10–30 倍($10^4\sim10^5$ 次/秒/核)。EdDSA 的签名与验证都可到 $10^4\sim10^5$ 次/秒/核。
  • 空间:签名 256 B(RSA-2048)/ 64 B(Ed25519)/ 约 70–72 B(ECDSA P-256 DER 编码)。
  • 通信:每条消息一个签名(RSA-2048 下约占一个 MTU 的 20%);因此签名验证常被放量使用,签名生成则常用于低频、高价值的操作(证书签发、软件发布、跨组织证据)。

算法 25.3.4:Needham-Schroeder 对称密钥认证协议与 Denning-Sacco 攻击

假设与系统模型

  • 参与方:$A$(Alice)、$B$(Bob)、$AS$(认证服务器)。$AS$ 与每个用户 $X$ 预共享长期密钥 $K_{X,AS}$,$AS$ 可信且其密钥永不泄露。
  • 攻击者:Dolev-Yao,可并发多会话、可重放、可注入;额外能力(本节的攻击前提):攻击者可能已经掌握了某一次历史会话的密钥 $K_{AB}^{\text{old}}$(通过破解、日志泄露、内部人员、或那次会话的老化实现漏洞)。
  • 随机数:$N_A, N_B$ 由各自主体的 CSPRNG 生成,不可预测、不可重复;协议为异步模型,各方的时钟不参与协议(原版 NS 不使用时间戳)。
  • 通道:不可靠、可乱序、可重复(重放是核心威胁)。

伪代码

// ===== 认证服务器 AS(无状态:不保存会话,只持有长期密钥表)=====
upon receive (A, B, N_A) from A:
    assert A, B are known principals
    K_AB ← GENERATE_FRESH_SESSION_KEY()          // 每次请求都生成新的会话密钥
    TICKET_B ← E(K_{B,AS}, {K_AB, A})            // 票据:只有 B 与 AS 能解
    REPLY_A ← E(K_{A,AS}, {N_A, B, K_AB, TICKET_B})
    send REPLY_A to A

// ===== Alice =====
procedure A_INITIATE(B):
    N_A ← RANDOM_NONCE()
    send (A, B, N_A) to AS
upon receive REPLY_A from AS:
    {N_A', B, K_AB, TICKET_B} ← D(K_{A,AS}, REPLY_A)
    assert N_A' = N_A                            // ★ 新鲜度检查:挡住对旧 REPLY_A 的重放
    assert B is the intended peer
    send TICKET_B to B                           // A 无法解密票据,只能转交

// ===== Bob =====
upon receive TICKET_B from anyone:
    {K_AB, A} ← D(K_{B,AS}, TICKET_B)            // ★ 原版 NS 缺少的就是这一步的新鲜度检查
    N_B ← RANDOM_NONCE()
    send E(K_AB, {N_B}) to A                     // 挑战:只有掌握 K_AB 的一方能应答
upon receive E(K_AB, {N_B'}) from A:
    assert N_B' = N_B − 1                        // 减 1:区分"应答"与"回显"
    AUTHENTICATED(A, K_AB)                       // Bob 认定对端是 A,会话密钥 K_AB 生效

// ===== Alice 的收尾 =====
upon receive E(K_AB, {N_B}) from B:
    send E(K_AB, {N_B − 1}) to B
    AUTHENTICATED(B, K_AB)

// ===== 攻击者的重放(Denning-Sacco):不需要任何密码学能力,只需要旧票据与旧密钥 =====
procedure MALLORY_REPLAY(old_session_key K_AB_old, recorded_ticket TICKET_B_old):
    send TICKET_B_old to B                        // ③' 原样重放
    n ← D(K_AB_old, RECEIVE_FROM_B())             // ④' 用泄露的旧密钥解开 Bob 的新挑战
    send E(K_AB_old, {n − 1}) to B                // ⑤' 完成应答
    // 结果:Bob 的 AUTHENTICATED("Alice") 被置为真,而 Alice 从未参与

算法逻辑解说(含数值小例子) 取 $N_A = 1001$、$N_B = 2002$、会话密钥 $K_{AB}$ 为 256 位随机值,长期密钥 $K_{A,AS}, K_{B,AS}$ 各 256 位:

  1. $A \to AS$:(A, B, 1001)
  2. $AS \to A$:$E(K_{A,AS}, \{1001, B, K_{AB}, E(K_{B,AS},\{K_{AB}, A\})\})$。Alice 解开后核对 1001 一致 ⇒ 确认这条应答是针对本次请求的(新鲜),得到 $K_{AB}$ 与一张她读不懂的票据;
  3. $A \to B$:转交票据;$B$ 用 $K_{B,AS}$ 解出 $\{K_{AB}, A\}$ ⇒ 知道”与谁、用什么密钥”;
  4. $B \to A$:$E(K_{AB}, \{2002\})$;
  5. $A \to B$:$E(K_{AB}, \{2001\})$ ⇒ Bob 验算通过,认证完成。 攻击路径:假设三天前那次会话的 $K_{AB}^{\text{old}}$ 泄露了,攻击者又录下了当时第 ③ 条消息的票据。现在它把旧票据原样发给 Bob(Bob 解出的仍是合法票据),Bob 生成新挑战 $N_B = 3003$,攻击者用泄露的旧密钥算出 3002 并回送——Bob 的认证判定为真。整个过程中攻击者没有解密任何长期密钥、没有伪造任何密文,它只是重放

正确性论证(安全性质)

  • 会话密钥的保密性(Safety-1,在假设 (A2) 下成立):$K_{AB}$ 只出现在两个密文中——$E(K_{A,AS}, \cdot)$ 与 $E(K_{B,AS}, \cdot)$。在”长期密钥保密 + 加密原语安全”的 Dolev-Yao 假设下,攻击者无法解开任何一个,故无法得到 $K_{AB}$。
  • 新鲜度与抗重放(Safety-2,部分成立):$N_A$ 保证 Alice 只接受”为本次请求生成的”应答(攻击者重放旧的 $REPLY_A$ 会因 $N_A$ 不匹配而被拒);$N_B$ 保证 Bob 只接受”针对本次挑战的”应答(攻击者若不掌握 $K_{AB}$,无法算出 $N_B - 1$)。但这条性质只覆盖”当前会话内部”,它不覆盖”整张票据是否是当前会话产生的”——因为 $N_A$ 只出现在 Alice 与 AS 之间,Bob 看不到它。
  • Denning-Sacco 反例(安全性被推翻的完整构造):把上面的假设弱化为 (A2’) 攻击者掌握任意一次历史会话密钥 $K_{AB}^{\text{old}}$(这是一个现实的假设:会话密钥的生命周期与存储安全性通常远低于长期密钥)。构造如伪代码所示:重放旧票据 + 用旧密钥应答新挑战。结论:
    1. Bob 的 AUTHENTICATED(A) 为真,然而 Alice 未参与认证性质不成立
    2. 攻击者掌握了会话密钥 $K_{AB}^{\text{old}}$ 并让 Bob 接受它为本次会话密钥 ⇒ 后续”Bob 与 Alice 的会话”完全在攻击者控制之下(可读可改);
    3. 因此 Needham-Schroeder 的安全性不是无条件的:它只在”历史会话密钥永不泄露”这一强假设下成立,而这个假设在实践中不成立。这就是为什么现代协议要求”票据必须携带新鲜度”(对比 25.3.5 Kerberos 的做法)。
  • 两条修复路线及其新引入的假设
    1. 时间戳票据(Kerberos 的选择):票据里加 T_start,$B$ 只接受 $\vert T_{now} - T_{start}\vert \le \Delta$ 的票据,并用重放缓存拒绝重复。代价:新的假设 (A4) 各主体时钟松同步($\vert \text{skew}\vert \le \Delta$),且 $\Delta$ 越大重放窗口越大、越小则合法请求越容易被误拒(25.4 代码 2 会实测”钟快 600 秒 ⇒ 合法请求被拒”)。
    2. 额外一轮挑战-应答(如 Otway-Rees、NS-Lowe 的改进思路):让 $B$ 也向 AS 提供一个 nonce,使票据内容包含”Bob 也认可的新鲜度”,从而不需要时钟。代价:多一个 RTT($AS$ 参与更多轮次)、$AS$ 的负载上升。
  • 活性(Liveness):三方各发 1–2 条消息,3 轮(5 条消息)完成;$AS$ 无状态、可线性扩展;只要网络最终交付且 $AS$ 可用,认证必然完成。单点注意:$AS$ 故障 ⇒ 新的认证无法进行(这正是 Kerberos KDC 单点问题的原型)。

复杂度

  • 消息复杂度:5 条消息、3 个 RTT(相对于直接认证的 3 条消息、1.5 个 RTT,Indirect 认证付出了约 2 倍的往返与对 $AS$ 的依赖)。
  • 计算复杂度:$AS$ 每次请求 2 次对称加密($O(1)$);Alice/Bob 各约 2–3 次对称加解密。
  • 存储复杂度:$AS$ 存 $O(N)$ 个长期密钥;客户端只需 1 个长期密钥 + 每个会话 1 个会话密钥。这正是它相对 $O(N^2)$ 全互联密钥的扩展性优势
  • 故障/攻击面:$AS$ 是可用性单点,也是最高价值攻击目标(持有全部长期密钥)。

算法 25.3.5:Kerberos 的完整认证流程(TGT、服务票据、双向认证)

假设与系统模型

  • 组件:$C$(客户端,主体 c@REALM)、$AS$、$TGS$(二者构成 KDC,物理上可同机)、$S$(应用服务器)。$AS$ 与 $C$ 之间共享由口令派生的密钥 $K_C$(在开启预认证后,客户端先用自己的密钥加密时间戳证明身份);KDC 与每个服务器共享 $K_S$;KDC 内部持有 $K_{TGS}$。
  • 时钟:各主体松同步,偏差上界 $\Delta = 300$ 秒(Kerberos 默认 5 分钟),时间源(NTP)在信任边界内。
  • 攻击者:Dolev-Yao,可窃听、重放、注入,且可以截获任何票据密文(票据是不加密的头 + 密文体,可被原样重放);不能破解对称加密与 HMAC。
  • 状态:$S$ 维护重放缓存 seen[(client, timestamp)],至少保留 $\Delta$ 时间;票据有明确生存期(TGT 与票据的生命周期由策略设定,数量级为小时)。

伪代码

// ===== 密钥与票据结构 =====
// TGT        = E(K_TGS, {c, tgs, T_start, lifetime, K_C_TGS})
// TICKET_S   = E(K_S,   {c, s,   T_start, lifetime, K_C_S})
// Authenticator = E(K_session, {c, T_now})     // E 为"带标签的认证加密",篡改即解密失败

// ===== 第一步:AS 交换(获得 TGT)=====
upon C wants to use service s:
    N1 ← RANDOM_NONCE()
    send (AS-REQ, c, tgs, N1, lifetime) to AS
upon AS receives (AS-REQ, c, tgs, N1, lifetime):
    assert PREAUTH_OK(c) = TRUE                  // 现代配置:先用 K_C 验证请求者(防离线爆破)
    K_C_TGS ← FRESH_RANDOM_KEY()
    TGT ← E(K_TGS, {c, tgs, T_now, lifetime, K_C_TGS})
    AS_REP ← E(K_C, {K_C_TGS, N1, TGT})
    send (AS-REP, AS_REP) to C
upon C receives (AS-REP, AS_REP):
    {K_C_TGS, N1', TGT} ← D(K_C, AS_REP)          // 只有正确口令派生的密钥才能解开
    assert N1' = N1                               // ★ 新鲜度:挡住重放旧的 AS-REP(TGT 仍有效时)
    STORE(TGT, K_C_TGS)

// ===== 第二步:TGS 交换(获得服务票据)=====
upon C wants TICKET_S:
    Auth1 ← E(K_C_TGS, {c, T_now})
    send (TGS-REQ, s, TGT, Auth1) to TGS
upon TGS receives (TGS-REQ, s, TGT, Auth1):
    {c, tgs, T_start, lifetime, K_C_TGS} ← D(K_TGS, TGT)   // 只有 KDC 能解开 ⇒ 客户端无法伪造
    assert T_start ≤ T_now < T_start + lifetime            // 票据自身未过期
    {c', T_auth} ← D(K_C_TGS, Auth1)                       // 只有持有 K_C_TGS 者能生成 ★
    assert c' = c and |T_now − T_auth| ≤ Δ                 // ★ 时钟窗口:防重放
    assert (c, T_auth) ∉ seen ;  seen ← seen ∪ {(c, T_auth)} // ★ 重放缓存:同期重复即拒
    K_C_S ← FRESH_RANDOM_KEY()
    TICKET_S ← E(K_S, {c, s, T_now, lifetime, K_C_S})
    TGS_REP ← E(K_C_TGS, {K_C_S, N2, TICKET_S})
    send (TGS-REP, TGS_REP) to C

// ===== 第三步:AP 交换(访问服务,可选双向认证)=====
upon C receives (TGS-REP, TGS_REP):
    {K_C_S, N2, TICKET_S} ← D(K_C_TGS, TGS_REP)
    Auth2 ← E(K_C_S, {c, T_now})
    send (AP-REQ, TICKET_S, Auth2) to S
upon S receives (AP-REQ, TICKET_S, Auth2):
    {c, s, T_start, lifetime, K_C_S} ← D(K_S, TICKET_S)     // 只有 KDC 与 S 共享 K_S ⇒ 不可伪造
    assert T_start ≤ T_now < T_start + lifetime
    {c', T_auth} ← D(K_C_S, Auth2)                          // 不知道 K_C_S 就造不出 Auth2
    assert c' = c and |T_now − T_auth| ≤ Δ
    assert (c, T_auth) ∉ seen ;  seen ← seen ∪ {(c, T_auth)} // 拒绝"同一认证符的第二次出现"
    if mutual_auth_requested:
        send (AP-REP, E(K_C_S, {T_now + 1})) to C           // 服务器证明自己也持有 K_C_S
    GRANT_ACCESS(c, K_C_S)

算法逻辑解说(含具体数值例子)

  1. 用户 alice@EXAMPLE.COM 在工作站执行 kinit(输入口令 ⇒ 派生 $K_C$)。$N_1 = 0x9A3F\ldots$(128 位随机数)随 AS-REQ 发出。
  2. AS 返回 AS-REP:Alice 用它派生的 $K_C$ 解密成功 ⇒ 既证明了 AS 是真的(只有 AS 知道 $K_C$ 的内容),也证明了她的口令正确;核对 $N_1$ 一致 ⇒ 应答新鲜。她得到 $K_{C,TGS}$ 与 TGTT_start = 10:00lifetime = 10h)。
  3. 访问 hdfs/namenode1 时,Alice 构造 Auth1 = E(K_{C,TGS}, {alice, 10:05:03}) 并连 TGT 一起发给 TGS。TGS 解 TGT 得 $K_{C,TGS}$、解 Auth1 得 (alice, 10:05:03):时间差 3 秒 ≤ 300 秒 ⇒ 通过;把 (alice, 10:05:03) 记入重放缓存。
  4. TGS 返回服务票据 TICKET_S(用 K_S 加密,Alice 打不开)与新会话密钥 $K_{C,S}$(用 $K_{C,TGS}$ 加密,只有 Alice 能打开)。
  5. Alice 用 Auth2 = E(K_{C,S}, {alice, 10:05:04}) 访问 NameNode;NameNode 用自己的 $K_S$ 解开票据 ⇒ 得到 $K_{C,S}$ ⇒ 解开 Auth2 ⇒ 校验时间与缓存 ⇒ 授权。
  6. 若启用双向认证,NameNode 回 E(K_{C,S}, {10:05:05})(时间戳 +1),Alice 解密并核对 ⇒ 她知道对面确实持有 $K_{C,S}$,即确实是那张票据对应的服务器

正确性论证(安全性质)

  • 票据不可伪造(Safety-1)TICKET_S 是用 $K_S$ 加密的,而 $K_S$ 只在 KDC 与 $S$ 之间共享。在对称加密安全的假设下,客户端(以及任何网络攻击者)既不能解密、也不能构造一个能被 $S$ 接受的票据。因此”服务端相信票据里的 $c$ 与 $K_{C,S}$”是安全的:这些内容只能来自 KDC。依赖:$K_S$ 的保密性(keytab 文件的权限与轮换是 Kerberos 运维的核心)与 KDC 的完整性(KDC 被攻破 = 全 realm 沦陷)。
  • Authenticator 防重放(Safety-2,本协议的灵魂):票据本身没有新鲜度(它只是一段可被原样重放的密文——这正是 Denning-Sacco 攻击的入口),因此防重放完全依赖 Authenticator:$S$ 要求”能生成 $E(K_{C,S}, \{c, T_{now}\})$”(需要 $K_{C,S}$,攻击者没有)“$T_{now}$ 落在窗口内”(挡住旧 Authenticator)”$(c, T_{now})$ 未出现过”(挡住窗口内的重复)。三条合起来给出:攻击者无法让 $S$ 接受一次它自己没在窗口内构造过的认证。形式化地说,$S$ 接受的每个认证都对应”某个持有 $K_{C,S}$ 的主体在 $S$ 的时钟窗口内发起的一次请求”。
  • 时钟依赖是明确写出的弱点(Safety-3,反面论证):上面的论证把安全性外包给了时钟。给定攻击者能影响的时钟偏差 $\epsilon$:
    • 若 $\epsilon \le \Delta$ 且无重放缓存:攻击者重放的 Authenticator 仍被接受 ⇒ 认证被绕过(因此重放缓存不是可选优化,而是必需组件);
    • 若攻击者能把某主体的时钟推快/推慢至偏差 $> \Delta$:合法用户的请求被误拒(可用性攻击),或(在时钟被大幅拉回后)旧的 Authenticator 重新落入窗口(若缓存已过期)⇒ 重放窗口被放大。具体攻击场景:攻击者在内网伪造 NTP 应答(NTP 无认证或未用 NTS),使某台应用服务器的时钟慢 1 小时;此后所有客户端发来的 Authenticator 都”来自未来”约 1 小时,全部被拒 ⇒ 该服务对全体用户不可用(一章式的”以时间为武器的 DoS”)。这也解释了为什么 $\Delta$ 不能随意调大:$\Delta = 5$ 分钟意味着”重放窗口 5 分钟”,$\Delta$ 越大越危险。
  • 口令猜测面(Safety-4):AS-REP 用口令派生密钥加密,若该密文可被离线获取(未开启预认证的 AS-REP、或攻击者能触发并录制),则可离线枚举口令:对字典中的每个候选口令派生 $K_C^{\prime}$,尝试解密 AS-REP,解密成功即命中口令(25.4 代码 3 会实测这一过程与尝试次数)。缓解:开启预认证(让攻击者必须在线交互且被审计/锁定)、高迭代 KDF、强口令策略、登录失败监控与锁定。
  • 活性(Liveness):三步流程共 3 个 RTT 量级(AS-REQ/REP、TGS-REQ/REP、AP-REQ/REP),第二步之后 TGT 可以缓存复用(在其生存期内访问任意服务都只花 TGS + AP 两步),因此”登录一次、全天可用”的成本被摊薄到接近直接认证。活性的薄弱点:KDC 不可用 ⇒ 无法获得新票据(已持有票据在生存期内仍可用);时钟偏差超窗口 ⇒ 全员认证失败(上一条)。

复杂度

  • 消息/延迟:首次 3 个 RTT,缓存 TGT 后 2 个 RTT;每个会话 O(1) 条消息。
  • 计算:每步 1–2 次对称加解密;$AS$ 每次登录 1 次口令派生(有意做得:PBKDF2/Argon2 的迭代是防离线爆破的手段,同时也是 KDC 的容量瓶颈)。
  • 存储:KDC 持 $O(N + M)$ 个长期密钥($N$ 用户、$M$ 服务);每个服务器只持 1 个 keytab;客户端只需 1 个口令派生密钥 + 少量票据缓存。
  • 可用性/信任:KDC 是单点信任容量瓶颈;realm 内一次登录全通(SSO)是优点,也是爆炸半径。

算法 25.3.6:TLS 1.3 握手(证书验证 + ECDHE + Finished)

假设与系统模型

  • 参与方:客户端 $C$、服务器 $S$。$S$ 持有长期证书($\text{cert}_S$,含 $pk_S$)与对应 $sk_S$;双方各有一个诚实但可能被攻击者控制的网络,以及一个 CSPRNG。
  • 信任前提:客户端有一个可信根集合 roots(预装根证书);CA 的签名在有效期内可信;客户端时钟可信(用于证书有效期检查,见 25.2.16 第 7 条)。
  • 攻击者:Dolev-Yao,且可以记录全部历史流量并长期保存(用于”先收集、后解密”)——因此前向保密是必需属性而非加分项。
  • 密码学假设:AEAD 安全、HKDF-HMAC 是伪随机函数、ECDHE 的 CDH 困难、签名不可伪造、转录哈希(transcript hash)抗碰撞
  • 密码套件:单一选定套件(如 TLS_AES_128_GCM_SHA256 / TLS_CHACHA20_POLY1305_SHA256);TLS 1.3 只允许 AEAD 与(EC)DHE。

伪代码

// ===== 客户端 =====
procedure C_HANDSHAKE(server_name, roots):
    R_C ← RANDOM(32) ; (a, A) ← ECDHE_KEYGEN()
    transcript ← H(ClientHello ‖ R_C ‖ suites ‖ A)
    send ClientHello(versions, suites, R_C, key_share = A)
    (R_S, B, cert_chain, cv_sig, fin_S) ← RECEIVE_SERVER_FLIGHT()
    transcript ← H(transcript ‖ ServerHello ‖ R_S ‖ B ‖ cert_chain ‖ cv_sig)
    // ① 证书与身份:把 pk_S 绑定到 server_name
    assert CERT_CHAIN_VALID(cert_chain, roots, server_name, NOW()) = TRUE   // 见算法 25.3.6 的检查清单
    pk_S ← LEAF_PUBLIC_KEY(cert_chain)
    // ② 证明对端持有私钥,且握手未被篡改(签名覆盖转录)
    assert SIGNATURE_VERIFY(pk_S, transcript, cv_sig) = TRUE
    // ③ 会话密钥:ECDHE + HKDF(见算法 25.3.2)
    Z ← CDH(a, B) ; assert Z ≠ 0
    (K_hs, K_c2s, K_s2c) ← HKDF_KEY_SCHEDULE(Z, R_C, R_S, transcript)
    // ④ 验证服务器 Finished:证明服务器算出了相同的密钥、且它看到的握手与我一致
    assert AEAD_OPEN(K_hs, fin_S) = H(transcript)   // 期望值绑定到转录
    DISCARD(a)                                       // ★ 丢弃临时私钥 ⇒ 前向保密
    send AEAD_SEAL(K_hs, H(transcript))              // 客户端 Finished
    return SECURE_CHANNEL(K_c2s, K_s2c, session_id)

// ===== 服务器 =====
procedure S_HANDSHAKE(sk_S, cert_chain):
    (ch, R_C, A) ← RECEIVE_CLIENT_HELLO()
    suite ← SELECT_SUITE(ch.suites)                  // 只从"安全集合"中选择,且与客户端有交集
    R_S ← RANDOM(32) ; (b, B) ← ECDHE_KEYGEN()
    transcript ← H(ch ‖ ServerHello ‖ R_S ‖ B)
    cv_sig ← SIGN(sk_S, transcript)                  // 用证书私钥对转录签名(CertificateVerify)
    Z ← CDH(b, A) ; (K_hs, K_c2s, K_s2c) ← HKDF_KEY_SCHEDULE(Z, R_C, R_S, transcript)
    send ServerHello(R_S, B, suite) ‖ AEAD_SEAL(K_hs, cert_chain ‖ cv_sig ‖ Finished)
    assert AEAD_OPEN(K_hs, RECEIVE_CLIENT_FINISHED()) = H(transcript)
    DISCARD(b)                                       // ★ 前向保密
    return SECURE_CHANNEL(K_c2s, K_s2c, session_id)

// 降级保护(TLS 1.3):ServerHello.random 末 8 字节固定填入 "DOWNGRD\x01"(协商到 1.2 时)
// 客户端一旦发现"本该 1.3 却看到降级哨兵或降级后的版本",必须中止握手

算法逻辑解说

  1. 客户端发送 ClientHello(含 $R_C$ 与临时 ECDHE 公钥 $A$);服务器回 ServerHello(含 $R_S$ 与 $B$),其后所有消息都被加密(TLS 1.3 相比 1.2 的重要改进:证书与签名不再明文暴露,减少元数据泄露与被动指纹)。
  2. 服务器发送 Certificate(链)+ CertificateVerify(用 $sk_S$ 对转录哈希签名)。这个签名同时做了两件事:证明私钥持有保护整段握手(任何字段被篡改 ⇒ 签名验证失败)。
  3. 客户端验证证书链(清单见 25.2.9 与本节 Safety-1),计算 $Z = B^a$,用 HKDF 派生握手密钥与应用密钥。
  4. 双方交换 Finished(对转录哈希的认证加密):它把”整段握手 + 双方随机数”绑在一起,任何降级/剪裁/篡改都会让 Finished 校验失败
  5. 之后应用数据用 AEAD 加密,密钥按方向分离($K_{c2s} \ne K_{s2c}$),并带序列号(作为 AAD 或 nonce 的一部分)以防记录层重放与重排。

正确性论证(安全性质)

  • 服务器认证(Safety-1,在 CA 可信前提下成立):客户端的每一步都可推导:CertificateVerify 验签通过 ⇒ 对端持有与 $\text{cert}_S$ 中公钥对应的私钥(签名不可伪造假设);证书链验证通过且链终止于 roots 中的根 ⇒ 由 CA 的签名(不可伪造)可知 $pk_S$ 与 $\text{SAN} = $ server_name 的绑定是 CA 认证过的;域名匹配 + 有效期 + 用途扩展检查 ⇒ 该证书确实为这个服务签发且当前有效。合并得:对端是 $server\_name$ 的合法持有者CA 被攻破时的信任崩塌(必须讨论):以上论证的唯一支撑是”CA 诚实且只为自己核验过的身份签发”。若 CA 被攻破(DigiNotar)或被施压签发(政府级 MITM),攻击者可以得到一张对 server_name 完全合法的证书,上述全部检查都会通过,客户端无从分辨。可用的补救都只是”检测/收敛”而非”防止”:Certificate Transparency 让错签可被发现(域主监控日志)、certificate pinning 让特定客户端只接受预设的 CA/公钥、短有效期缩小暴露窗口、多方见证(如使用多份独立日志的一致性证明)。因此”信任 CA”是本章最重的信任假设之一,必须写进系统文档。
  • 握手完整性 / 抗降级(Safety-2):Finished 与 CertificateVerify 都覆盖转录哈希,攻击者若把套件列表或版本改成更弱的,会导致:(a) 转录哈希改变 ⇒ 签名验证失败;(b) 或(对未认证部分)Finished 校验失败。此外 TLS 1.3 在 ServerHello 随机数里放了降级哨兵,让”协商被悄悄降级”可被客户端主动检测边界:这防的是”降级到弱密码学”,不防“服务端配置本身就弱”(例如仍开放 TLS 1.0)——那属于配置问题(见 25.2.13)。
  • 会话密钥保密性与前向保密(Safety-3):$Z = g^{ab}$ 由双方的临时私钥决定,且两个私钥在使用后立即丢弃。攻击者能记录的只有 $A, B$ 与加密记录。若它在会话结束后获得 $sk_S$:$sk_S$ 只用于签名,无法从签名反推 $Z$,因此历史会话不可解密 ⇒ 前向保密成立。论证的三个必要前提必须同时成立:(a) $a, b$ 来自高质量 CSPRNG;(b) 临时私钥真的被丢弃(不落盘、不进日志、不因内存转储泄露);(c) 每会话独立(临时密钥不复用)。任意一条不成立,前向保密的证明立刻作废——这也是为什么”会话票据(session ticket)的长期密钥”必须同样谨慎管理。
  • 防重放的能力边界(Safety-4):1-RTT 握手内,$R_C, R_S$ 的新鲜度与 Finished 的转录绑定使攻击者无法重放一次完整握手(重放会因随机数不匹配而失败)。但 0-RTT(Early Data)不提供重放保护:客户端用 PSK 直接加密的第一批数据可以被攻击者原样重放给服务器,因此 0-RTT 只应用于幂等请求(HTTP 的 GET、幂等的 API),并且服务器应实现单次性票据(anti-replay ticket)或时间窗口校验。这是”性能换安全”的教科书级权衡(见 25.5)。
  • 活性(Liveness):1-RTT 内交换 2 条飞行(flight);只要双方诚实、$Z \ne 0$(算法上检查以避免退化情形)、证书链可验证,握手必然终止。实际活性风险来自策略层:OCSP/CRL 不可达时的超时(若采用 hard-fail)、证书链过深、服务器缺少中间证书导致客户端无法构链(这是最常见的”生产事故型”活性故障)。

复杂度

  • 时间/延迟:TLS 1.3 = 1 RTT(TLS 1.2 为 2 RTT),叠加 TCP 三次握手后首次连接共 2 RTT;0-RTT 恢复可把应用数据放在第一个飞行里。
  • 计算:双方各 1 次 ECDHE 标量乘 + 1 次签名(服务器)/验签(客户端)+ 若干次 HMAC;证书链验证(可能含吊销查询)通常是客户端侧的最大开销
  • 空间/带宽:$R_C, R_S$ 各 32 B;key_share 各 32–64 B;证书链 1–4 KB(可压缩/可省略中间证书以减少流量);Finished 各约 32 B。
  • 后续数据:对称 AEAD,$O(\vert M\vert )$ 且为 GB/s 级(见 25.5)——握手的成本是”一次性固定成本”,数据传输的成本是”线性成本”,这正是混合加密的根本原因。

25.4 代码示例与分布式实现

本节给出三段可直接 python3 运行的代码(只用 Python 标准库,随机种子固定,输出可复现)。唯一随机器波动的是 PBKDF2 的实测墙钟耗时(代码 1 的第 (c) 节与代码 3 的第 4 节用 time.perf_counter() 现场测量,因此那几行秒数、以及由它外推的”年数/小时数”在不同机器上不同;其余输出——包括全部密文十六进制、nonce、断言结果与拒绝原因——由固定种子保证逐字节一致)。必须先读这段免责声明

Python 标准库没有 AES、RSA、TLS 与 Kerberos——hashlibhmacsecretsosstruct 提供的是哈希、HMAC 与随机数。因此本节的做法是:(a) 用标准库真实地演示哈希、HMAC、PBKDF2、口令存储与篡改检测;(b) 用自己实现的简化教学版演示 DH、RSA、对称”加密”与协议流程的原理所有自实现的密码学构造都仅用于教学,绝不可用于生产:真实系统必须使用经过审计的实现与标准协议(AES-GCM、RSA-OAEP/PSS、ECDSA/Ed25519、TLS 1.3、Kerberos V5、Argon2id)。代码中的”对称加密”用”SHA-256 派生的密钥流 XOR + HMAC 标签”模拟 AEAD,它把保密性与完整性拆成两步,与真实 AEAD 的构造不同,只用来展示协议的消息形状与新鲜度检查。

25.4.1 代码 1:密码学原语演示(哈希、HMAC、口令存储、DH+MITM、RSA、MAC vs 签名)

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
CS 425 Lecture 25 配套代码 1 —— 分布式系统中的密码学原语(教学演示)
运行方式:python3 c25_code1_crypto.py
本文件的 DH、RSA、流密码均为教学简化版,【仅用于教学,绝不可用于生产】;
真实系统必须使用经审计的实现:ECDHE、RSA-OAEP / RSA-PSS、AES-GCM、Argon2id。
随机性固定种子,输出可复现。"""

import hashlib
import hmac
import random
import struct
import time

random.seed(425)          # 固定随机种子:保证每次运行输出完全一致
BAR = "=" * 74


def head(tag, title):
    """打印分节标题。"""
    print(BAR)
    print(f"{tag} {title}")
    print(BAR)


def human(sec):
    """把秒数格式化成易读的时间量级。"""
    if sec < 1e-3:
        return f"{sec * 1e6:.1f} 微秒"
    if sec < 1:
        return f"{sec * 1e3:.1f} 毫秒"
    if sec < 3600:
        return f"{sec:.1f} 秒"
    if sec < 86400:
        return f"{sec / 3600:.1f} 小时"
    if sec < 3.15e7:
        return f"{sec / 86400:.1f} 天"
    return f"{sec / 3.15e7:.1f} 年"


def xor_stream(data, shared_int):
    """教学级流密码:以共享密钥为种子做 SHA-256 计数器模式密钥流,
    加解密是同一操作(异或自反)。【仅用于教学,绝不可用于生产】"""
    seed = struct.pack(">I", shared_int)
    out = bytearray()
    for blk in range((len(data) + 31) // 32):
        ks = hashlib.sha256(seed + struct.pack(">I", blk)).digest()
        out.extend(b ^ k for b, k in zip(data[blk * 32:blk * 32 + 32], ks))
    return bytes(out)


def demo1():
    head("[1]", "哈希与雪崩效应(Avalanche Effect)与定长输出")
    m1 = b"cs425"
    m2 = bytes([m1[0] ^ 0x01]) + m1[1:]                    # 只翻转最低 1 个比特
    h1, h2 = hashlib.sha256(m1).hexdigest(), hashlib.sha256(m2).hexdigest()
    bits = bin(int(h1, 16) ^ int(h2, 16)).count("1")
    chars = sum(1 for x, y in zip(h1, h2) if x != y)
    md5_len = len(hashlib.md5(m1).hexdigest())
    hbig = hashlib.sha256(bytes(random.randrange(256) for _ in range(100000))).hexdigest()
    print(f"  A = {m1!r}  ->  {h1}\n  B = {m2!r}  ->  {h2}   (B 只比 A 差 1 个比特)")
    print(f"  输出定长 = {len(h1)} 个 hex 字符 = {len(h1) * 4} 比特;不同的 hex 字符 "
          f"{chars}/64(约 60),不同的比特 {bits}/256 ≈ 一半")
    assert len(h1) == 64 and chars > 50 and 40 <= bits <= 216, "雪崩效应:应接近半数比特改变"
    print(f"  对比 MD5:{md5_len} 个 hex 字符(128 比特);100000 字节大消息的 SHA-256 "
          f"仍为 {len(hbig)} 个字符:{hbig[:32]}...")
    assert md5_len == 32 and len(hbig) == 64, "无论输入多长,SHA-256 都是 256 比特定长输出"
    print("  结论:哈希把任意长度输入压成定长摘要,输入微小变化则输出面目全非。")


def demo2():
    head("[2]", "单向性(不可逆)与小空间暴力搜索")
    pin = "%04d" % random.randrange(10000)
    digest = hashlib.sha256(pin.encode()).hexdigest()
    for i in range(10000):
        if hashlib.sha256(("%04d" % i).encode()).hexdigest() == digest:
            attempts, found = i + 1, "%04d" % i
            break
    print(f"  口令空间 = 全部 4 位数字 PIN(10000 个),已知摘要 = {digest[:40]}...")
    print(f"  暴力搜索(0000 起顺序枚举):尝试 {attempts} 次后恢复出口令 = {found}")
    assert found == pin, "小空间必然可枚举,暴力搜索一定能找回原口令"
    space4, space8, rate = 10 ** 4, 26 ** 8, 10 ** 9
    assert space8 == 208827064576
    print(f"  空间增长:4 位 PIN = {space4} 个;8 位小写字母 26^8 = {space8} 个"
          f"(是前者的 {space8 / space4:.2e} 倍)")
    print(f"  按每秒可试 {rate:.0e} 个哈希算:PIN 需 {human(space4 / rate)},"
          f"26^8 需 {human(space8 / rate)}")
    print("  所以“不可逆”只是计算意义上的:口令熵太低时穷举照旧可行——加盐 + 慢速 KDF"
          "并加大长度/字符集(见第 4 节)。")


def demo3():
    head("[3]", "HMAC 消息认证码与篡改检测")

    def mac(key, msg):
        return hmac.new(key, msg, hashlib.sha256).hexdigest()

    key, msg = b"K_AB_shared_secret", b"Alice transfer 100 yuan to Bob"
    tampered = b"Alice transfer 999 yuan to Bob"           # 篡改金额 100 -> 999
    tag = mac(key, msg)
    print(f"  K_AB = {key!r}   M = {msg!r}\n  MAC(K_AB, M) = {tag}")
    print(f"  验证正确 MAC -> {hmac.compare_digest(mac(key, msg), tag)}")
    assert hmac.compare_digest(mac(key, msg), tag) is True
    print(f"  篡改消息(100->999)后验证 -> {hmac.compare_digest(mac(key, tampered), tag)};"
          f"用错误密钥验证 -> {hmac.compare_digest(mac(b'K_wrong', tampered), tag)}")
    assert hmac.compare_digest(mac(key, tampered), tag) is False, "消息改动必须导致校验失败"
    assert hmac.compare_digest(mac(b"K_wrong_secret", tampered), tag) is False, "错误密钥必须失败"
    print("  为何用 hmac.compare_digest:普通 == 遇首个不同字节即返回,攻击者可测量响应时间"
          "逐字节猜 MAC(时序侧信道);compare_digest 是常量时间比较。")


def demo4():
    head("[4]", "口令存储三种方式对比:明文 / 无盐哈希 / 加盐慢速 KDF")
    bases = ["123456", "password", "qwerty", "abc123", "iloveyou", "admin", "letmein",
             "welcome", "dragon", "monkey", "sunshine", "princess", "football", "shadow",
             "master", "hello", "freedom", "secret", "trustno1", "cs425"]
    common = []                                    # 约 200 个常见口令构成的小字典
    for w in bases:
        common.append(w)
        common.extend(w + str(i) for i in range(1, 10))
    target_pw = "cs4257"
    print(f"  (a) 明文存储(错误示范):数据库里直接是 password='{target_pw}',拖库即泄露")
    assert target_pw in common

    table = {hashlib.sha256(p.encode()).hexdigest(): p for p in common}   # 预计算表/彩虹表
    sha_hash = hashlib.sha256(target_pw.encode()).hexdigest()
    print(f"  (b) 无盐 SHA-256(仍然错误):小字典 {len(common)} 个口令 -> 预计算表 "
          f"{len(table)} 条,构建成本仅 {len(common)} 次哈希")
    print(f"      泄露的哈希 = {sha_hash}")
    assert table.get(sha_hash) == target_pw, "无盐哈希可被预计算表一次命中"
    print(f"      查表破解:1 次哈希表查找(O(1))就得到明文 = {table.get(sha_hash)}")
    same = hashlib.sha256(b"123456").hexdigest() == hashlib.sha256(b"123456").hexdigest()
    print(f"      相同口令哈希必然相同 -> {same},故预计算表可一次构建、反复复用")
    assert same is True

    iters, salt = 200_000, b"\x1f\x8a\x03\xc7\x55\xee\x21\x90\x4d\x0b\x36\xa8\x7c\x12\xd9\x64"
    print(f"  (c) 加盐 + 慢速 KDF(PBKDF2-HMAC-SHA256,迭代 {iters} 次)——正确方向")
    print(f"      固定盐 = {salt!r}(此处固定盐仅为输出可复现;"
          f"生产环境必须用 secrets.token_bytes(16))")
    t0 = time.perf_counter()
    dk = hashlib.pbkdf2_hmac("sha256", target_pw.encode(), salt, iters)
    dt = time.perf_counter() - t0
    print(f"      单次 PBKDF2 实测耗时 = {dt:.4f} 秒,派生密钥 = {dk.hex()[:32]}...")
    again = hashlib.pbkdf2_hmac("sha256", target_pw.encode(), salt, iters)
    assert hmac.compare_digest(again, dk), "同口令 + 同盐必须可复现"
    print(f"      同口令 + 同盐可复现 -> True;同口令 + 不同盐 -> 存储值不同 -> "
          f"{dk != hashlib.pbkdf2_hmac('sha256', target_pw.encode(), b'X' * 16, iters)}")
    assert dk != hashlib.pbkdf2_hmac("sha256", target_pw.encode(), b"X" * 16, iters), "加盐后必须不同"
    total8 = 26 ** 8 * dt
    assert total8 > 3.15e7, "慢速 KDF 应让 26^8 的穷举至少超过 1 年"
    print(f"      攻击者总耗时外推(按实测 {dt:.4f} 秒/次):字典 {len(common)} 个口令需 "
          f"{human(len(common) * dt)};26^8 个候选需 {total8:.3e} 秒 ≈ {human(total8)}")


def demo5():
    head("[5]", "Diffie-Hellman 密钥交换 与 中间人攻击(MITM)")
    p, g = 23, 5    # 【仅用于教学,绝不可用于生产】真实系统用 2048 位以上素数或 ECDHE
    print(f"  教学参数 p = {p}, g = {g}(真实系统使用 2048 位以上素数或 ECDHE)")
    print("  [A 部分] 正常握手:无攻击者")
    a, b = 6, 15
    A, B = pow(g, a, p), pow(g, b, p)
    ka, kb = pow(B, a, p), pow(A, b, p)
    print(f"    Alice: a={a} -> A=g^a mod p={A};Bob: b={b} -> B=g^b mod p={B}")
    print(f"    Alice 算 B^a mod p = {ka};Bob 算 A^b mod p = {kb};两者相同 -> {ka == kb}")
    assert ka == kb, "DH 双方必须独立算出相同的共享密钥"
    print("    窃听者只见 A、B,在真实参数下求离散对数不可行 —— 这正是 DH 的安全性来源")

    print("  [B 部分] MITM:Mallory 分别与 Alice、Bob 建立两条 DH 会话,替换双方公钥")
    am, bm = 7, 11
    A_M, B_M = pow(g, am, p), pow(g, bm, p)
    k_am_side, k_am_mallory = pow(A_M, a, p), pow(A, am, p)    # K_AM:Alice <-> Mallory
    k_mb_side, k_mb_mallory = pow(B_M, b, p), pow(B, bm, p)    # K_MB:Bob   <-> Mallory
    assert k_am_side == k_am_mallory and k_mb_side == k_mb_mallory, "两条会话各自密钥一致"
    assert k_am_side != k_mb_side, "两条会话密钥不同,Mallory 才能实时解密再重加密"
    print(f"    Mallory 发给 Alice 的假公钥 A_M={A_M},发给 Bob 的假公钥 B_M={B_M}")
    print(f"    K_AM: Alice={k_am_side} = Mallory={k_am_mallory};"
          f"K_MB: Bob={k_mb_side} = Mallory={k_mb_mallory}")

    plain = "Alice: 转账 100 元给 Bob".encode("utf-8")
    wire = xor_stream(plain, k_am_side)                    # Alice -> Mallory
    stolen = xor_stream(wire, k_am_mallory)                # Mallory 解密
    forged = xor_stream(stolen, k_mb_mallory)              # Mallory 用 K_MB 重加密
    got = xor_stream(forged, k_mb_side)                    # Bob 解密
    print(f"    Alice 发出的密文(hex 前 32) = {wire.hex()[:32]}...")
    print(f"    Mallory 解出明文 = {stolen.decode('utf-8')}")
    print(f"    Bob 收到明文     = {got.decode('utf-8')}")
    assert stolen == plain, "Mallory 必须能完整读出明文(教学级流密码演示)"
    assert got == plain, "Bob 收到正确消息 —— 双方都察觉不到攻击"
    print("  结论:裸 DH 只能防窃听,无法抵抗 MITM;公钥必须由证书/签名与身份绑定。"
          "即使换成 2048 位 DH,缺少认证同样会被中间人替换公钥。")


def rsa_keygen(p, q, e):
    """【仅用于教学,绝不可用于生产】小素数 RSA 密钥生成。"""
    n, phi = p * q, (p - 1) * (q - 1)
    return n, e, pow(e, -1, phi)           # pow(e, -1, phi) 需 Python 3.8+


def rsa_encrypt(m, e, n):
    return pow(m, e, n)                    # 教科书式 RSA,无填充,禁止用于生产


def rsa_decrypt(c, d, n):
    return pow(c, d, n)


def rsa_sign(h, d, n):
    return pow(h, d, n)


def rsa_verify(h, sig, e, n):
    return pow(sig, e, n) == h


def demo6():
    head("[6]", "RSA 教学版:加密与签名(小素数,仅演示数学原理)")
    n, e, d = rsa_keygen(61, 53, 17)       # 【仅用于教学,绝不可用于生产】
    print(f"  p=61, q=53, n=p*q={n}, phi=(p-1)(q-1)=3120, e=17, d=e^-1 mod phi={d}")
    c = rsa_encrypt(65, e, n)
    print(f"  公钥 (n,e)=({n},{e}),私钥 (n,d)=({n},{d});加密 65 -> {c},解密 -> "
          f"{rsa_decrypt(c, d, n)}")
    assert rsa_decrypt(rsa_encrypt(65, e, n), d, n) == 65, "RSA 加解密必须可逆"
    h = int(hashlib.sha256(b"Alice transfer 100 yuan").hexdigest(), 16) % n
    sig = rsa_sign(h, d, n)
    print(f"  签名:H(M) mod n = {h} -> sig = H(M)^d mod n = {sig};"
          f"验证 sig^e mod n == H(M) -> {rsa_verify(h, sig, e, n)}")
    assert rsa_verify(h, sig, e, n) is True
    h2 = int(hashlib.sha256(b"Alice transfer 999 yuan").hexdigest(), 16) % n
    print(f"  篡改消息后 H(M') mod n = {h2},用原签名验证 -> {rsa_verify(h2, sig, e, n)};"
          f"翻转签名 1 比特后验证 -> {rsa_verify(h, sig ^ 1, e, n)}")
    assert h2 != h and rsa_verify(h2, sig, e, n) is False and rsa_verify(h, sig ^ 1, e, n) is False
    print(f"  模数规模:n 仅 {n.bit_length()} 比特。真实 RSA 用 2048~4096 比特模数,且必须配 "
          f"OAEP 加密填充 / PSS 签名填充,绝不使用裸教科书 RSA。")


def demo7():
    head("[7]", "MAC vs 数字签名:不可否认性(Non-repudiation)")
    key_ab = b"K_AB_shared_secret"
    msg, forged = b"Alice transfer 100 yuan to Bob", b"Alice transfer 1000 yuan to Bob"
    tag = hmac.new(key_ab, msg, hashlib.sha256).hexdigest()
    print("  (a) 共享密钥 MAC:Alice 与 Bob 持有同一把 K_AB")
    print(f"      Alice 算出的 MAC = {tag[:32]}...")
    assert tag == hmac.new(key_ab, msg, hashlib.sha256).hexdigest(), "同一密钥必然算出同一 MAC"
    print(f"      Bob   算出的 MAC = {hmac.new(key_ab, msg, hashlib.sha256).hexdigest()[:32]}..."
          f"  两者完全相同 -> True")
    forged_tag = hmac.new(key_ab, forged, hashlib.sha256).hexdigest()
    judge_ok = hmac.compare_digest(hmac.new(key_ab, forged, hashlib.sha256).hexdigest(), forged_tag)
    assert judge_ok is True, "法官(也持有 K_AB)无法判断这条 MAC 是谁算的"
    print(f"      Bob 伪造 Alice 从未发送的消息 {forged!r},其 MAC = {forged_tag[:32]}...")
    print(f"      法官用 K_AB 验证这条伪造 MAC -> {judge_ok}(校验“通过”,但无法归因)")
    print("""      场景:Alice『我从没转过 1000 元!』 Bob『MAC 校验通过,这就是证据。』
            法官『密钥你们两人都有,这份 MAC 谁都能造 —— 无法裁决。』""")

    print("  (b) RSA 数字签名:私钥签名、公钥验证")
    n_a, e_a, d_a = rsa_keygen(61, 53, 17)     # Alice 的密钥对
    n_b, e_b, d_b = rsa_keygen(47, 59, 17)     # Bob 自己的密钥对
    h = int(hashlib.sha256(msg).hexdigest(), 16) % n_a
    sig_a = rsa_sign(h, d_a, n_a)
    assert rsa_verify(h, sig_a, e_a, n_a) is True
    print(f"      Alice 公钥 (n={n_a}, e={e_a});真消息签名 sig_A={sig_a},"
          f"用 Alice 公钥验证 -> True(Bob 只有公钥,无法伪造)")
    h_forged = int(hashlib.sha256(forged).hexdigest(), 16) % n_a
    sig_bob = rsa_sign(int(hashlib.sha256(forged).hexdigest(), 16) % n_b, d_b, n_b)
    print(f"      伪造消息:复用 Alice 真签名 -> {rsa_verify(h_forged, sig_a, e_a, n_a)};"
          f"改用 Bob 自己的私钥签名再拿 Alice 公钥验 -> {rsa_verify(h_forged, sig_bob, e_a, n_a)}")
    assert rsa_verify(h_forged, sig_a, e_a, n_a) is False, "没有 d_A 就无法伪造 Alice 的签名"
    assert rsa_verify(h_forged, sig_bob, e_a, n_a) is False, "Bob 的私钥签不出 Alice 的签名"
    print("""      场景:Alice 抵赖 → 法官用 Alice 公钥验证真消息:通过;验证伪造消息:失败。
            签名只能由私钥 d_A 生成,Alice 无法抵赖。""")
    print("""  结论:MAC 提供完整性 + 认证,但双方共享密钥 ⇒ 无不可否认性;
        数字签名(私钥签、公钥验)⇒ 具备不可否认性(前提是私钥不泄露)。""")


def main():
    demo1()
    demo2()
    demo3()
    demo4()
    demo5()
    demo6()
    demo7()
    head("[8]", "总结(Takeaways)")
    print("""  1. 哈希 = 定长 + 抗原像 + 抗碰撞;雪崩效应让任意一位改动都面目全非。
  2. 单向性只是“计算上不可逆”:口令熵太低时穷举仍可行,必须扩容并加盐。
  3. MAC(HMAC + compare_digest)能查篡改,但共享密钥下没有不可否认性。
  4. 口令必须加盐 + 慢速 KDF(PBKDF2 / bcrypt / Argon2id),逐用户随机盐。
  5. 裸 Diffie-Hellman 挡不住 MITM;公钥必须由证书/签名与身份绑定。
  6. 教科书 RSA 只用于理解数学;生产用 RSA-OAEP/PSS 或 ECDSA/Ed25519。
  7. 黄金法则:完整性与认证靠共享密钥,不可否认性靠私钥签名。""")
    print(BAR)
    print("全部断言通过(all asserts passed),无异常退出。")


if __name__ == "__main__":
    main()

实际运行输出(完整,可复现)

==========================================================================
[1] 哈希与雪崩效应(Avalanche Effect)与定长输出
==========================================================================
  A = b'cs425'  ->  97ed841e61d3ef1fac1e50aa8d4e4da931bdf1f6c50f5439bc8ff8c9e1b31006
  B = b'bs425'  ->  b94dce9dcc159c030b5958d17e802a7b9cd8444b17675247b4717afeabf8448e   (B 只比 A 差 1 个比特)
  输出定长 = 64 个 hex 字符 = 256 比特;不同的 hex 字符 60/64(约 60),不同的比特 127/256 ≈ 一半
  对比 MD5:32 个 hex 字符(128 比特);100000 字节大消息的 SHA-256 仍为 64 个字符:e1defc0476e57a9003271397d3538ec7...
  结论:哈希把任意长度输入压成定长摘要,输入微小变化则输出面目全非。
==========================================================================
[2] 单向性(不可逆)与小空间暴力搜索
==========================================================================
  口令空间 = 全部 4 位数字 PIN(10000 个),已知摘要 = eb697e886e44483d3a39ca573938ad5843a12558...
  暴力搜索(0000 起顺序枚举):尝试 7474 次后恢复出口令 = 7473
  空间增长:4 位 PIN = 10000 个;8 位小写字母 26^8 = 208827064576 个(是前者的 2.09e+07 倍)
  按每秒可试 1e+09 个哈希算:PIN 需 10.0 微秒,26^8 需 208.8 秒
  所以“不可逆”只是计算意义上的:口令熵太低时穷举照旧可行——加盐 + 慢速 KDF并加大长度/字符集(见第 4 节)。
==========================================================================
[3] HMAC 消息认证码与篡改检测
==========================================================================
  K_AB = b'K_AB_shared_secret'   M = b'Alice transfer 100 yuan to Bob'
  MAC(K_AB, M) = 4a821a6170899e68c34138d8f24b97bcfc8df698a783af22f2196e44070e7a73
  验证正确 MAC -> True
  篡改消息(100->999)后验证 -> False;用错误密钥验证 -> False
  为何用 hmac.compare_digest:普通 == 遇首个不同字节即返回,攻击者可测量响应时间逐字节猜 MAC(时序侧信道);compare_digest 是常量时间比较。
==========================================================================
[4] 口令存储三种方式对比:明文 / 无盐哈希 / 加盐慢速 KDF
==========================================================================
  (a) 明文存储(错误示范):数据库里直接是 password='cs4257',拖库即泄露
  (b) 无盐 SHA-256(仍然错误):小字典 200 个口令 -> 预计算表 200 条,构建成本仅 200 次哈希
      泄露的哈希 = bfd7398360fb8b8a6eb3de5262a0ee6671fd5c4e0f6b0d02bb77797fb82446d0
      查表破解:1 次哈希表查找(O(1))就得到明文 = cs4257
      相同口令哈希必然相同 -> True,故预计算表可一次构建、反复复用
  (c) 加盐 + 慢速 KDF(PBKDF2-HMAC-SHA256,迭代 200000 次)——正确方向
      固定盐 = b'\x1f\x8a\x03\xc7U\xee!\x90M\x0b6\xa8|\x12\xd9d'(此处固定盐仅为输出可复现;生产环境必须用 secrets.token_bytes(16))
      单次 PBKDF2 实测耗时 = 0.0388 秒,派生密钥 = facb7c47863d03fa7cd867cf293520e7...
      同口令 + 同盐可复现 -> True;同口令 + 不同盐 -> 存储值不同 -> True
      攻击者总耗时外推(按实测 0.0388 秒/次):字典 200 个口令需 7.8 秒;26^8 个候选需 8.098e+09 秒 ≈ 257.1 年
==========================================================================
[5] Diffie-Hellman 密钥交换 与 中间人攻击(MITM)
==========================================================================
  教学参数 p = 23, g = 5(真实系统使用 2048 位以上素数或 ECDHE)
  [A 部分] 正常握手:无攻击者
    Alice: a=6 -> A=g^a mod p=8;Bob: b=15 -> B=g^b mod p=19
    Alice 算 B^a mod p = 2;Bob 算 A^b mod p = 2;两者相同 -> True
    窃听者只见 A、B,在真实参数下求离散对数不可行 —— 这正是 DH 的安全性来源
  [B 部分] MITM:Mallory 分别与 Alice、Bob 建立两条 DH 会话,替换双方公钥
    Mallory 发给 Alice 的假公钥 A_M=17,发给 Bob 的假公钥 B_M=22
    K_AM: Alice=12 = Mallory=12;K_MB: Bob=22 = Mallory=22
    Alice 发出的密文(hex 前 32) = fec447faacf7a673b786348e836100ca...
    Mallory 解出明文 = Alice: 转账 100 元给 Bob
    Bob 收到明文     = Alice: 转账 100 元给 Bob
  结论:裸 DH 只能防窃听,无法抵抗 MITM;公钥必须由证书/签名与身份绑定。即使换成 2048 位 DH,缺少认证同样会被中间人替换公钥。
==========================================================================
[6] RSA 教学版:加密与签名(小素数,仅演示数学原理)
==========================================================================
  p=61, q=53, n=p*q=3233, phi=(p-1)(q-1)=3120, e=17, d=e^-1 mod phi=2753
  公钥 (n,e)=(3233,17),私钥 (n,d)=(3233,2753);加密 65 -> 2790,解密 -> 65
  签名:H(M) mod n = 2952 -> sig = H(M)^d mod n = 3134;验证 sig^e mod n == H(M) -> True
  篡改消息后 H(M') mod n = 2087,用原签名验证 -> False;翻转签名 1 比特后验证 -> False
  模数规模:n 仅 12 比特。真实 RSA 用 2048~4096 比特模数,且必须配 OAEP 加密填充 / PSS 签名填充,绝不使用裸教科书 RSA。
==========================================================================
[7] MAC vs 数字签名:不可否认性(Non-repudiation)
==========================================================================
  (a) 共享密钥 MAC:Alice 与 Bob 持有同一把 K_AB
      Alice 算出的 MAC = 4a821a6170899e68c34138d8f24b97bc...
      Bob   算出的 MAC = 4a821a6170899e68c34138d8f24b97bc...  两者完全相同 -> True
      Bob 伪造 Alice 从未发送的消息 b'Alice transfer 1000 yuan to Bob',其 MAC = 70c1f470a909426c924d8434f76a2a62...
      法官用 K_AB 验证这条伪造 MAC -> True(校验“通过”,但无法归因)
      场景:Alice『我从没转过 1000 元!』 Bob『MAC 校验通过,这就是证据。』
            法官『密钥你们两人都有,这份 MAC 谁都能造 —— 无法裁决。』
  (b) RSA 数字签名:私钥签名、公钥验证
      Alice 公钥 (n=3233, e=17);真消息签名 sig_A=2427,用 Alice 公钥验证 -> True(Bob 只有公钥,无法伪造)
      伪造消息:复用 Alice 真签名 -> False;改用 Bob 自己的私钥签名再拿 Alice 公钥验 -> False
      场景:Alice 抵赖 → 法官用 Alice 公钥验证真消息:通过;验证伪造消息:失败。
            签名只能由私钥 d_A 生成,Alice 无法抵赖。
  结论:MAC 提供完整性 + 认证,但双方共享密钥 ⇒ 无不可否认性;
        数字签名(私钥签、公钥验)⇒ 具备不可否认性(前提是私钥不泄露)。
==========================================================================
[8] 总结(Takeaways)
==========================================================================
  1. 哈希 = 定长 + 抗原像 + 抗碰撞;雪崩效应让任意一位改动都面目全非。
  2. 单向性只是“计算上不可逆”:口令熵太低时穷举仍可行,必须扩容并加盐。
  3. MAC(HMAC + compare_digest)能查篡改,但共享密钥下没有不可否认性。
  4. 口令必须加盐 + 慢速 KDF(PBKDF2 / bcrypt / Argon2id),逐用户随机盐。
  5. 裸 Diffie-Hellman 挡不住 MITM;公钥必须由证书/签名与身份绑定。
  6. 教科书 RSA 只用于理解数学;生产用 RSA-OAEP/PSS 或 ECDSA/Ed25519。
  7. 黄金法则:完整性与认证靠共享密钥,不可否认性靠私钥签名。
==========================================================================
全部断言通过(all asserts passed),无异常退出。

【代码做什么?】

  1. demo1 哈希与雪崩效应:对只差 1 个比特的两个输入(cs425bs425)分别算 SHA-256,输出两个十六进制摘要,并统计”有多少个 hex 字符不同、多少个比特不同”(实测 64 个 hex 字符中有 60 个不同、256 比特中有 127 比特翻转 ≈ 一半);同时打印 MD5(32 个 hex 字符)与 SHA-256(64 个 hex 字符)的长度差,以及”10 万字节的大消息仍然只输出 64 个字符”来体现定长输出
  2. demo2 单向性与小空间穷举:对 4 位数字 PIN(空间 $10^4$)的 SHA-256 摘要做顺序暴力搜索,打印尝试次数(实测 7474 次后命中口令 7473);再外推 8 位小写字母($26^8 \approx 2.09\times10^{11}$)在每秒 $10^9$ 次哈希下的耗时,说明”不可逆”只是计算意义上的困难,口令熵太低时穷举依然可行。
  3. demo3 HMAC 与篡改检测:用 hmac.new(key, msg, hashlib.sha256) 生成标签;先验证正确消息(True),再把转账金额从 100 改成 999(False),再换错密钥(False);并说明为什么必须用 hmac.compare_digest 而不是 ==常量时间比较,否则攻击者可通过响应时间逐字节猜出标签——时序侧信道)。
  4. demo4 口令存储的三种做法(a) 明文存储(打印出来示众,拖库即泄露);(b) 无盐 SHA-256:现场构建一个 200 条目的预计算表(彩虹表的雏形),然后用1 次查表就恢复了明文口令——实测证明”无盐哈希等于没哈希”;(c) 加盐 + 慢哈希hashlib.pbkdf2_hmac('sha256', pw, salt, 200_000),用 time.perf_counter() 实测单次派生耗时(约 0.039 秒),并据此外推攻击者字典攻击的总成本(200 个口令约 7.8 秒,$26^8$ 个候选约 257 年);同时验证”同口令 + 不同盐 ⇒ 存储值不同”。
  5. demo5 Diffie-Hellman 与 MITM(本节最重要的演示):先用教学参数 $p=23, g=5$ 走完 DH(Alice $a=6 \Rightarrow A=8$,Bob $b=15 \Rightarrow B=19$,双方独立算出共享秘密 $2$);然后让 Mallory 同时与 Alice、Bob 建立两条 DH 会话(把公钥分别换成 17 与 22),得到两个不同的共享密钥 $K_{AM}$ 与 $K_{MB}$;Alice 用 $K_{AM}$ 加密的”转账 100 元”被 Mallory 解出明文,再改用 $K_{MB}$ 重新加密发给 Bob,Bob 收到的明文完全正确、双方都没有察觉
  6. demo6 RSA 教学版:$p=61, q=53 \Rightarrow n=3233$、$\varphi=3120$、$e=17, d=2753$,实现加密/解密与签名/验签,断言 decrypt(encrypt(m)) == m、验签为 True;再篡改消息或翻转签名 1 个比特,验证变为 False;并打印”模数只有 12 比特,真实 RSA 需要 2048–4096 比特 + OAEP/PSS 填充”。
  7. demo7 MAC vs 数字签名(不可否认性):对同一段消息分别用共享密钥 MAC 与 RSA 签名处理。先演示 Alice 与 Bob 算出的 MAC 完全相同(⇒ 双方都能造),Bob 伪造一条 Alice 从未发送的消息并生成合法 MAC;”法官”用共享密钥验证通过,于是陷入”Alice 说是 Bob 造的、Bob 说是 Alice 发的”无法裁决的场面。再换成 RSA 签名:Bob 没有 $d_A$,伪造消息验签失败;真消息用 Alice 公钥验签通过 ⇒ 可归因、可裁决。
  8. demo8 总结:把七条结论打印出来(哈希性质、单向性的计算含义、MAC 的局限、口令存储要求、裸 DH 的 MITM 问题、教科书 RSA 的适用范围、”完整性靠共享密钥、不可否认性靠私钥签名”)。

【分布式机制透视】

  • 这段代码没有网络,但它在模拟分布式安全的三个”接口”(i) 消息与密钥的分离——E(k, obj) 的返回值(IV + 密文 + 标签)就是”上线路的字节”,而 k 是”离线的秘密”,这正对应真实系统中”数据走网络、密钥走 KMS/HSM”的结构;(ii) 攻击者就是网络——demo5 里的 Mallory 不是”破解者”,而是一个中间节点,它做的每一步(替换公钥、解密、重加密)都对应 Dolev-Yao 模型允许的操作,因此这个演示证明的是”协议缺少认证”而非”密码学被攻破”;(iii) 时间与随机性是一等公民——random.seed(425) 让输出可复现,但真实系统必须使用 secrets.token_bytes()(CSPRNG);nonce 与盐一旦可预测,代码里所有”看起来正确”的安全性都会消失。
  • 代码中每个”教学简化”都对应一个真实的失败模式:无盐哈希对应彩虹表攻击;单次哈希对应 GPU 每秒 $10^9$ 量级的离线爆破;E(k,obj) 先用 XOR、再加 HMAC 对应”encrypt-then-MAC”的顺序(顺序错了就会被填充预言攻击);DH 用 $p=23$ 对应弱参数被离散对数直接破解(Logjam 类);RSA 用 12 比特对应”密钥太短”这一最古老也最致命的错误。

【与理论的对应】

  • demo1/demo2 验证 25.2.7 的三条哈希性质与”哈希 ≠ 加密”:不可逆(只能靠穷举)、定长、雪崩;demo3 验证 25.2.7 的 HMAC 构造与 25.2.4 第 ③ 条(消息篡改)的防御。
  • demo4 直接对应 25.2.7 第 3 条口令存储要求:(a)(b)(c) 三种做法的对比就是”为什么必须加盐 + 慢哈希”的实测证明
  • demo5 是 25.2.6 与 25.2.9 的反例验证:DH 给出共享秘密(Safety 正常),但没有身份绑定 ⇒ MITM 成立;它同时演示了”为什么裸 DH 必须与证书结合”这一结论。
  • demo6 对应 25.2.8 的签名流程与”先哈希再签名”的必要性;demo7 对应 25.2.8 的核心表格(MAC 无不可否认性 vs 签名有不可否认性)——这是本章最常考的对比,而这段代码把它从”背诵”变成了”看到法官无法裁决”

25.4.2 代码 2:Needham-Schroeder 协议模拟、重放攻击与 Denning-Sacco 攻击

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""CS425 分布式系统安全 Lecture 25 (2/2): Needham-Schroeder 共享密钥认证协议
覆盖: 正常 5 条消息流程 / Denning-Sacco 重放攻击 / Kerberos 式时间戳票据修复。
【仅用于教学】全部密码学构造都是教学玩具, 绝不可用于生产: E(k,obj) = CTR 风格 keystream XOR
(长度前缀+JSON) 再附 HMAC-SHA256 标签, 篡改可检测; 长期密钥 = sha256(可读标签); 票据与会话密钥
在转录里保持人类可读 (kab#1, N_A=1001)。随机数来自 random.seed(425) 以保证讲义输出可复现 ——
真实协议必须使用 secrets / os.urandom (CSPRNG)。
"""
import hashlib
import hmac
import json
import random
import struct

TAG_LEN = 16
SESSION = [0]          # 全局会话密钥计数器, 使转录里的 K_AB 全局唯一 (kab#1, kab#2, ...)
N = [0]                # 报文序号


# ---------------------------------------------------------------- 教学用原语
def ltk(label):
    """长期/共享密钥: 由可读标签派生 32 字节, 例如 ltk("K_A_AS")。"""
    return hashlib.sha256(label.encode("utf-8")).digest()


def ks(k, iv, n):
    """【仅用于教学】CTR 风格 keystream: sha256(k | iv | counter) 拼接后截断。"""
    out, ctr = b"", 0
    while len(out) < n:
        out += hashlib.sha256(k + b"|" + iv + b"|" + str(ctr).encode()).digest()
        ctr += 1
    return out[:n]


def E(k, obj):
    """【仅用于教学】E(k,obj) = iv(8)|ct|tag(16); IV 取自 seeded PRNG 以便讲义输出可复现。"""
    iv = random.getrandbits(64).to_bytes(8, "big")
    body = json.dumps(obj, sort_keys=True, ensure_ascii=False).encode("utf-8")
    pt = struct.pack(">I", len(body)) + body
    ct = bytes(a ^ b for a, b in zip(pt, ks(k, iv, len(pt))))
    return iv + ct + hmac.new(k, iv + ct, hashlib.sha256).digest()[:TAG_LEN]


def D(k, blob, who="?"):
    """解密; HMAC 标签错误 -> 打印告警并返回 None (调用方必须检查)。"""
    if not isinstance(blob, (bytes, bytearray)) or len(blob) < 12 + TAG_LEN:
        print("            !! %s: 密文长度非法" % who)
        return None
    iv, ct, tag = bytes(blob[:8]), bytes(blob[8:-TAG_LEN]), bytes(blob[-TAG_LEN:])
    if not hmac.compare_digest(tag, hmac.new(k, iv + ct, hashlib.sha256).digest()[:TAG_LEN]):
        print("            !! %s: HMAC 标签校验失败 -> 拒绝 (返回 None)" % who)
        return None
    pt = bytes(a ^ b for a, b in zip(ct, ks(k, iv, len(ct))))
    return json.loads(pt[4:4 + struct.unpack(">I", pt[:4])[0]].decode("utf-8"))


class Clock(object):
    """全局模拟时钟, 便于确定性地推进时间 (时间戳方案需要)。"""
    def __init__(self, t=1000000): self.t = t

    def now(self): return self.t

    def advance(self, dt):
        self.t += dt
        return self.t


CLOCK = Clock()
j = lambda o: json.dumps(o, ensure_ascii=False, sort_keys=True)
hx = lambda b, n=20: bytes(b).hex()[:n] + ("..." if len(bytes(b).hex()) > n else "")
new_nonce = lambda: random.randrange(1000, 10000)     # 【真实协议必须用 CSPRNG】


def msg(sender, receiver, what, blob=None, plain=None, decrypt=None):
    N[0] += 1
    print("  [msg %d] %-8s -> %-8s: %s" % (N[0], sender, receiver, what))
    if plain is not None: print("           明文      : %s" % plain)
    if blob is not None: print("           密文 (%3dB): %s" % (len(blob), hx(blob)))
    if decrypt is not None: print("           解密得到  : %s" % decrypt)


def section(title): print("\n" + "=" * 74 + "\n" + title + "\n" + "=" * 74)


# ------------------------------------------------------- 主体: 状态显式可打印
class Principal(object):
    def __init__(self, name, keys, skew=0):
        self.name, self.keys, self.skew = name, dict(keys), skew      # skew: 本机时钟偏移(秒)
        self.log, self.peer, self.kab, self.authenticated = [], None, None, False

    def info(self, event, **kw):
        self.log.append(dict({"who": self.name, "event": event}, **kw))
        return self.log[-1]

    def local_now(self): return CLOCK.now() + self.skew


class Alice(Principal):
    def step1_request(self, as_name, bob, na):
        self.na = na
        self.info("send_req", to=as_name, N_A=na)
        return {"A": self.name, "B": bob, "N_A": na}                   # msg1 是明文
    def step2_recv(self, blob):
        """解密 AS 回包并检查 N_A —— 原版 NS 中 Alice 唯一的防重放手段。"""
        body = D(self.keys["K_A_AS"], blob, who=self.name)
        if body is None or body["N_A"] != self.na:
            self.info("reject_as_reply", why="N_A 不匹配")
            return None, None
        self.peer, self.kab = body["B"], body["K_AB"].encode()
        self.ticket = bytes.fromhex(body["TICKET"])
        self.info("got_ticket", peer=self.peer, K_AB=body["K_AB"])
        return self.ticket, body
    def step4_recv_challenge(self, blob):
        ch = D(self.kab, blob, who=self.name)
        if ch is None: return None
        self.nb_seen = ch["N_B"]
        self.authenticated = True            # 只有掌握 K_AB 的 B 才能发出新鲜挑战 N_B
        self.info("authenticated", peer=self.peer, N_B=self.nb_seen)
        return ch["N_B"]
    def step5_answer(self): return E(self.kab, {"N_B": self.nb_seen - 1})


class AS(Principal):
    def __init__(self, name, keys, mode="ns", lifetime=600, skew=0):
        Principal.__init__(self, name, keys, skew)
        self.mode, self.lifetime = mode, lifetime      # ns=原版(票据无时间戳); ticket=修复版
    def new_session_key(self):
        SESSION[0] += 1
        return ("kab#%d" % SESSION[0]).encode()
    def step2_reply(self, req):
        kab = self.new_session_key()
        if self.mode == "ns":
            ticket = {"K_AB": kab.decode(), "A": req["A"]}
        else:   # 修复: 票据携带签发时间与有效期 —— 让 B 能独立判断新鲜度
            ticket = {"K_AB": kab.decode(), "A": req["A"], "T_issue": self.local_now(), "lifetime": self.lifetime}
        tb = E(self.keys["K_B_AS"], ticket)
        self.info("issue", K_AB=kab.decode(), ticket=ticket)
        return E(self.keys["K_A_AS"], {"N_A": req["N_A"], "B": req["B"], "K_AB": kab.decode(),
                                       "TICKET": tb.hex()}), tb


class Bob(Principal):
    def __init__(self, name, keys, mode="ns", window=300, skew=0):
        Principal.__init__(self, name, keys, skew)
        self.mode, self.window = mode, window
        self.seen, self.sessions = {}, 0       # seen: 重放缓存 (client,T_issue) -> 接受时刻
    def reject(self, why):
        self.authenticated = False
        self.info("reject", why=why)
        return None, why
    def step4_recv_ticket(self, blob):
        """收到票据 -> (回复密文, 拒绝原因); 回复为 None 表示拒绝。"""
        tk = D(self.keys["K_B_AS"], blob, who=self.name)
        if tk is None: return self.reject("票据无法用 K_B_AS 解密 / HMAC 标签错误")
        if self.mode == "ticket":
            age = abs(self.local_now() - tk["T_issue"])
            if age > self.window:
                return self.reject("时间戳超出窗口: |T_now-T_issue| = %d s > window = %d s" % (age, self.window))
            if (tk["A"], tk["T_issue"]) in self.seen:
                return self.reject("重放缓存命中: (client=%s, T_issue=%d) 已用过" % (tk["A"], tk["T_issue"]))
            self.seen[(tk["A"], tk["T_issue"])] = self.local_now()
        self.peer, self.kab, self.authenticated = tk["A"], tk["K_AB"].encode(), False
        self.nb = new_nonce()
        self.info("ticket_ok_pending_challenge", peer=self.peer, K_AB=tk["K_AB"], N_B=self.nb)
        return E(self.kab, {"N_B": self.nb}), None
    def step5_verify(self, blob):
        r = D(self.kab, blob, who=self.name)
        if r is None or r.get("N_B") != self.nb - 1: return self.reject("挑战应答错误 (N_B-1 不匹配)")
        self.authenticated, self.sessions = True, self.sessions + 1
        self.info("authenticated", peer=self.peer)
        return True, None


class Mallory(Principal):
    """攻击者: 没有 K_A_AS / K_B_AS, 只有泄露的旧会话密钥与被录下的密文。"""
    def __init__(self, name="Mallory"):
        Principal.__init__(self, name, {})
        self.leaked, self.recorded = {}, {}


# ------------------------------------------------------------- 完整 5 条消息
def run_flow(alice, as_, bob, na=None):
    na = na if na is not None else new_nonce()
    m1 = alice.step1_request(as_.name, bob.name, na)
    msg(alice.name, as_.name, "1) 请求 {A, B, N_A}", plain=j(m1))
    m2, ticket = as_.step2_reply(m1)
    msg(as_.name, alice.name, "2) E(K_A_AS, {N_A, B, K_AB, TICKET_b})", blob=m2)
    print("           【旁白】AS 写进票据的内容: %s (只有 B 能用 K_B_AS 解开)" % j(as_.log[-1]["ticket"]))
    tk, body = alice.step2_recv(m2)
    if tk is None:
        print("           解密得到  : <拒绝> N_A 不新鲜, Alice 中止")
        return {"ok": False, "K_AB": None, "ticket": None}
    print("           解密得到  : %s" % j({"N_A": body["N_A"], "B": body["B"],
                                           "K_AB": body["K_AB"], "TICKET": hx(tk, 24)}))
    msg(alice.name, bob.name, "3) 转发票据 E(K_B_AS, {...})", blob=tk)
    reply, why = bob.step4_recv_ticket(tk)
    print("           B 解密票据: %s" % j(D(bob.keys["K_B_AS"], tk, who="(旁白)")))
    if reply is None:
        print("           >>> Bob 拒绝本次会话: %s" % why)
        return {"ok": False, "K_AB": alice.kab, "ticket": tk, "why": why}
    msg(bob.name, alice.name, "4) E(K_AB, {N_B}) 新挑战", blob=reply,
        decrypt="N_B = %d      <- 只有刚拿到 K_AB 的 Alice 解得开" % bob.nb)
    alice.step4_recv_challenge(reply)
    m5 = alice.step5_answer()
    msg(alice.name, bob.name, "5) E(K_AB, {N_B - 1}) 应答", blob=m5,
        decrypt="N_B - 1 = %d  <- 只有从票据拿到 K_AB 的主体答得出" % (bob.nb - 1))
    ok, why = bob.step5_verify(m5)
    print("           >>> Bob %s (peer=%s)" % ("认证通过" if ok else "拒绝: " + str(why), bob.peer))
    return {"ok": ok, "K_AB": alice.kab, "ticket": tk, "why": why}


# -------------------------------------------------------------------- 各场景
def sec0():
    section("[0] 建立长期密钥: AS 分别与 Alice / Bob 共享一把密钥 【仅用于教学】")
    keytab = {"K_A_AS": ltk("K_A_AS"), "K_B_AS": ltk("K_B_AS")}
    for label in ("K_A_AS", "K_B_AS"):
        print("  %-7s = sha256(b\"%s\") = %s... (32 B)" % (label, label, hx(keytab[label], 32)))
    a_keys, as_keys, b_keys = {"K_A_AS": keytab["K_A_AS"]}, dict(keytab), {"K_B_AS": keytab["K_B_AS"]}
    assert a_keys["K_A_AS"] == as_keys["K_A_AS"] and b_keys["K_B_AS"] == as_keys["K_B_AS"]
    assert a_keys["K_A_AS"] != b_keys["K_B_AS"]
    print("  >>> 断言成立: K_A_AS 仅 A/AS 知道, K_B_AS 仅 B/AS 知道, 且两者互不相同。")
    return a_keys, as_keys, b_keys


def sec1(a_keys, as_keys, b_keys):
    section("[1] Needham-Schroeder 正常流程 (5 条消息)")
    N[0] = 0
    alice, bob = Alice("Alice", a_keys), Bob("Bob", b_keys)
    r = run_flow(alice, AS("AS", as_keys), bob)
    assert r["ok"] and bob.authenticated and alice.authenticated
    assert alice.kab == bob.kab == r["K_AB"] and alice.peer == "Bob" and bob.peer == "Alice"
    print("\n  结论: Alice 相信对端是 Bob —— K_AB=%s 由 AS 签发, 且 B 用新鲜 N_B 挑战了她;" % alice.kab.decode())
    print("        Bob 相信对端是 Alice —— 只有从 AS 取到票据并答对 N_B-1 的主体才知道 K_AB。")
    print("        双方持有同一会话密钥 K_AB = %s (assert 通过)。" % alice.kab.decode())


def sec2(a_keys, as_keys, b_keys):
    section("[2] 攻击 1 —— 重放旧的会话密钥分发消息 (Denning-Sacco) 【最重要的演示】")
    print("  背景: 上一次会话的 K_AB_old 已泄露 (被破解 / 会话被攻陷), Mallory 当时录下了第 3 条消息")
    print("        中的旧票据 E(K_B_AS, {K_AB_old, A})。")
    N[0] = 0
    r_old = run_flow(Alice("Alice", a_keys), AS("AS", as_keys), Bob("Bob", b_keys))
    assert r_old["ok"]
    kab_old, ticket_old = r_old["K_AB"], r_old["ticket"]
    print("\n  [录制完成] K_AB_old = %s, 旧票据 %d B —— Mallory 只有这两样东西"
          % (kab_old.decode(), len(ticket_old)))
    mallory = Mallory()
    mallory.leaked["old"], mallory.recorded["ticket"] = kab_old, ticket_old
    print("\n  --- 攻击开始: Mallory 把旧票据当作一次全新会话的第 3 条消息发给 Bob ---")
    N[0] = 0
    alice2, bob2 = Alice("Alice", a_keys), Bob("Bob", b_keys)     # 真 Alice: 本次会话完全不参与
    msg(mallory.name, bob2.name, "3') 重放录下的旧票据", blob=ticket_old)
    reply, why = bob2.step4_recv_ticket(ticket_old)
    print("           B 解密票据: %s" % j(D(b_keys["K_B_AS"], ticket_old, who="(旁白)")))
    assert reply is not None, "原版 NS 中 Bob 必须无法识别重放"
    msg(bob2.name, mallory.name, "4) E(K_AB_old, {N_B2}) 全新挑战", blob=reply,
        decrypt="N_B2 = %d  (Mallory 用泄露的 %s 解开)" % (bob2.nb, kab_old.decode()))
    ch = D(mallory.leaked["old"], reply, who="Mallory")
    m5 = E(mallory.leaked["old"], {"N_B": ch["N_B"] - 1})
    msg(mallory.name, bob2.name, "5') E(K_AB_old, {N_B2 - 1})", blob=m5,
        decrypt="N_B2 - 1 = %d" % (ch["N_B"] - 1))
    bob2.step5_verify(m5)
    print("           B 状态: peer=%s, authenticated=%s, sessions=%d"
          % (bob2.peer, bob2.authenticated, bob2.sessions))
    assert bob2.peer == "Alice" and bob2.authenticated is True and bob2.sessions == 1
    assert alice2.kab is None and alice2.authenticated is False
    print("\n  >>> 断言成立: Bob 的 peer == \"Alice\" 且 authenticated == True, 尽管 Alice 从未参与。")
    print("""
  【结论】Bob 现在相信他在和 Alice 说话, 而真正的 Alice 从未参与本次会话。
  根因: Bob 无法区分"新票据"与"被重放的旧票据" —— 票据里没有新鲜度,
        nonce (N_A) 只在 Alice 与 AS 之间出现过, B 根本看不到它。
  【备注】在原版 Needham-Schroeder 中 Bob 不检查票据的新鲜度, 这正是 Denning-Sacco 缺陷;
        攻击者不需要 K_A_AS, 只需要一个过去泄露的会话密钥 + 一段录下的密文。""")


def sec3(a_keys, as_keys, b_keys):
    section("[3] 修复 —— Kerberos 式时间戳票据 + 时间窗口 + 重放缓存")
    print("  新票据格式: E(K_B_AS, {K_AB, A, T_issue, lifetime}); window = 300 s")
    print("  Bob 现在检查: |T_now - T_issue| <= window, 且 (client, T_issue) 不在重放缓存中。")
    print("\n  [3.1] 重放 3600 s 之前签发的旧票据 -> 应被拒绝")
    N[0] = 0
    r31 = run_flow(Alice("Alice", a_keys), AS("AS", as_keys, mode="ticket"), Bob("Bob", b_keys, mode="ticket"))
    assert r31["ok"]
    CLOCK.advance(3600)
    print("  [模拟时钟] T_now = %d (比该票据的 T_issue 晚 3600 s)" % CLOCK.now())
    bob31 = Bob("Bob", b_keys, mode="ticket")                     # 状态干净的 Bob
    N[0] = 0
    msg("Mallory", "Bob", "3') 重放 1 小时前的旧票据", blob=r31["ticket"])
    reply31, why31 = bob31.step4_recv_ticket(r31["ticket"])
    print("           >>> Bob 拒绝原因: %s" % why31)
    print("           B 状态: authenticated=%s, sessions=%d" % (bob31.authenticated, bob31.sessions))
    assert reply31 is None and bob31.authenticated is False and bob31.sessions == 0
    print("\n  [3.2] 当前时刻的新鲜票据 -> 应被接受")
    N[0] = 0
    bob32 = Bob("Bob", b_keys, mode="ticket")
    r32 = run_flow(Alice("Alice", a_keys), AS("AS", as_keys, mode="ticket"), bob32)
    assert r32["ok"] and bob32.authenticated and bob32.sessions == 1
    print("           >>> 断言成立: 新鲜票据通过, Bob.authenticated = True")
    print("\n  [3.3] 修复的代价 (a): 时钟偏移 +600 s -> 合法的新鲜请求被误拒")
    N[0] = 0
    print("  [模拟] 发起方一侧时钟快 600 s > window = 300 s, 但票据本身完全合法")
    r33 = run_flow(Alice("Alice", a_keys), AS("AS", as_keys, mode="ticket", skew=600),
                   Bob("Bob", b_keys, mode="ticket"))
    assert r33["ok"] is False
    print("           >>> 时钟偏移导致合法请求被拒 —— 连接 Lecture 11/26 的时钟同步")
    print("\n  [3.4] 修复的代价 (b): 窗口内重放同一张新鲜票据 -> 重放缓存拒绝")
    N[0] = 0
    bob34 = Bob("Bob", b_keys, mode="ticket")
    r34 = run_flow(Alice("Alice", a_keys), AS("AS", as_keys, mode="ticket"), bob34)
    print("           >>> 第一次(合法): ok=%s, sessions=%d" % (r34["ok"], bob34.sessions))
    N[0] = 0
    msg("Mallory", "Bob", "3'') 重放刚才那张新鲜票据 (仍在窗口内)", blob=r34["ticket"])
    reply34, why34 = bob34.step4_recv_ticket(r34["ticket"])
    print("           >>> 第二次(重放): ok=%s, 原因: %s" % (reply34 is not None, why34))
    assert reply34 is None and bob34.sessions == 1
    print("           >>> 断言成立: 缓存命中, 窗口内的精确重放被拦截。")
    print("""
  【总结】时间戳方案把"防重放"外包给了时钟同步, 因此引入对 NTP 的依赖:
        窗口内的重放要靠 replay cache 兜底, 而时钟偏移/漂移会让合法请求被误拒。""")


def main():
    random.seed(425)      # 固定随机性, 保证整个转录可复现
    CLOCK.t = 1000000
    a_keys, as_keys, b_keys = sec0()
    sec1(a_keys, as_keys, b_keys)
    sec2(a_keys, as_keys, b_keys)
    sec3(a_keys, as_keys, b_keys)
    section("总结: 三条教训")
    print("""
  [1] nonce 只对生成者新鲜: N_A 只保护 Alice <-> AS 这一段, Bob 看不到它,
      因此 Bob 无法判断手里的票据是新签发的还是被重放的。
  [2] 票据需要自己的新鲜度: 时间戳/序号必须能被接收者 (B) 独立验证 —— Denning-Sacco 的教训。
  [3] 时间戳换时钟依赖: 修复用时间戳换掉了"无新鲜度", 代价是把安全性外包给时钟同步 (NTP),
      窗口内的重放仍需 replay cache 兜底。
  【仅用于教学】以上原语均为玩具实现, 真实系统请使用成熟协议/库 (Kerberos v5 / TLS 1.3)。""")
    print("=" * 74)
    print("全部场景完成: 所有 assert 通过, 退出码 0")


if __name__ == "__main__":
    main()

实际运行输出(节选,可复现;省略了各条消息的密文十六进制行)

==========================================================================
[0] 建立长期密钥: AS 分别与 Alice / Bob 共享一把密钥 【仅用于教学】
==========================================================================
  >>> 断言成立: K_A_AS 仅 A/AS 知道, K_B_AS 仅 B/AS 知道, 且两者互不相同。
==========================================================================
[1] Needham-Schroeder 正常流程 (5 条消息)
==========================================================================
           明文      : {"A": "Alice", "B": "Bob", "N_A": 7865}
           【旁白】AS 写进票据的内容: {"A": "Alice", "K_AB": "kab#1"} (只有 B 能用 K_B_AS 解开)
           解密得到  : {"B": "Bob", "K_AB": "kab#1", "N_A": 7865, "TICKET": "2bfe4a3c16ebbc52c3ddb79c..."}
           B 解密票据: {"A": "Alice", "K_AB": "kab#1"}
           解密得到  : N_B = 4663      <- 只有刚拿到 K_AB 的 Alice 解得开
           解密得到  : N_B - 1 = 4662  <- 只有从票据拿到 K_AB 的主体答得出
           >>> Bob 认证通过 (peer=Alice)
  结论: Alice 相信对端是 Bob —— K_AB=kab#1 由 AS 签发, 且 B 用新鲜 N_B 挑战了她;
        Bob 相信对端是 Alice —— 只有从 AS 取到票据并答对 N_B-1 的主体才知道 K_AB。
        双方持有同一会话密钥 K_AB = kab#1 (assert 通过)。
==========================================================================
[2] 攻击 1 —— 重放旧的会话密钥分发消息 (Denning-Sacco) 【最重要的演示】
==========================================================================
  背景: 上一次会话的 K_AB_old 已泄露 (被破解 / 会话被攻陷), Mallory 当时录下了第 3 条消息
        中的旧票据 E(K_B_AS, {K_AB_old, A})。
           明文      : {"A": "Alice", "B": "Bob", "N_A": 4709}
           【旁白】AS 写进票据的内容: {"A": "Alice", "K_AB": "kab#2"} (只有 B 能用 K_B_AS 解开)
           解密得到  : {"B": "Bob", "K_AB": "kab#2", "N_A": 4709, "TICKET": "ac03360899fe757d7f2df6a1..."}
           B 解密票据: {"A": "Alice", "K_AB": "kab#2"}
           解密得到  : N_B = 9805      <- 只有刚拿到 K_AB 的 Alice 解得开
           解密得到  : N_B - 1 = 9804  <- 只有从票据拿到 K_AB 的主体答得出
           >>> Bob 认证通过 (peer=Alice)
  [录制完成] K_AB_old = kab#2, 旧票据 59 B —— Mallory 只有这两样东西
           B 解密票据: {"A": "Alice", "K_AB": "kab#2"}
           解密得到  : N_B2 = 7106  (Mallory 用泄露的 kab#2 解开)
           解密得到  : N_B2 - 1 = 7105
           B 状态: peer=Alice, authenticated=True, sessions=1
  >>> 断言成立: Bob 的 peer == "Alice" 且 authenticated == True, 尽管 Alice 从未参与。
  【结论】Bob 现在相信他在和 Alice 说话, 而真正的 Alice 从未参与本次会话。
  根因: Bob 无法区分"新票据"与"被重放的旧票据" —— 票据里没有新鲜度,
        nonce (N_A) 只在 Alice 与 AS 之间出现过, B 根本看不到它。
  【备注】在原版 Needham-Schroeder 中 Bob 不检查票据的新鲜度, 这正是 Denning-Sacco 缺陷;
        攻击者不需要 K_A_AS, 只需要一个过去泄露的会话密钥 + 一段录下的密文。
==========================================================================
[3] 修复 —— Kerberos 式时间戳票据 + 时间窗口 + 重放缓存
==========================================================================
  新票据格式: E(K_B_AS, {K_AB, A, T_issue, lifetime}); window = 300 s
  Bob 现在检查: |T_now - T_issue| <= window, 且 (client, T_issue) 不在重放缓存中。
  [3.1] 重放 3600 s 之前签发的旧票据 -> 应被拒绝
           明文      : {"A": "Alice", "B": "Bob", "N_A": 2648}
           【旁白】AS 写进票据的内容: {"A": "Alice", "K_AB": "kab#3", "T_issue": 1000000, "lifetime": 600} (只有 B 能用 K_B_AS 解开)
           解密得到  : {"B": "Bob", "K_AB": "kab#3", "N_A": 2648, "TICKET": "ce2a17af85c4384e2f9e7999..."}
           B 解密票据: {"A": "Alice", "K_AB": "kab#3", "T_issue": 1000000, "lifetime": 600}
           解密得到  : N_B = 1984      <- 只有刚拿到 K_AB 的 Alice 解得开
           解密得到  : N_B - 1 = 1983  <- 只有从票据拿到 K_AB 的主体答得出
           >>> Bob 认证通过 (peer=Alice)
  [模拟时钟] T_now = 1003600 (比该票据的 T_issue 晚 3600 s)
           >>> Bob 拒绝原因: 时间戳超出窗口: |T_now-T_issue| = 3600 s > window = 300 s
           B 状态: authenticated=False, sessions=0
  [3.2] 当前时刻的新鲜票据 -> 应被接受
           明文      : {"A": "Alice", "B": "Bob", "N_A": 4086}
           【旁白】AS 写进票据的内容: {"A": "Alice", "K_AB": "kab#4", "T_issue": 1003600, "lifetime": 600} (只有 B 能用 K_B_AS 解开)
           解密得到  : {"B": "Bob", "K_AB": "kab#4", "N_A": 4086, "TICKET": "4af7129ec16a3344cd612211..."}
           B 解密票据: {"A": "Alice", "K_AB": "kab#4", "T_issue": 1003600, "lifetime": 600}
           解密得到  : N_B = 4052      <- 只有刚拿到 K_AB 的 Alice 解得开
           解密得到  : N_B - 1 = 4051  <- 只有从票据拿到 K_AB 的主体答得出
           >>> Bob 认证通过 (peer=Alice)
           >>> 断言成立: 新鲜票据通过, Bob.authenticated = True
  [3.3] 修复的代价 (a): 时钟偏移 +600 s -> 合法的新鲜请求被误拒
  [模拟] 发起方一侧时钟快 600 s > window = 300 s, 但票据本身完全合法
           明文      : {"A": "Alice", "B": "Bob", "N_A": 2559}
           【旁白】AS 写进票据的内容: {"A": "Alice", "K_AB": "kab#5", "T_issue": 1004200, "lifetime": 600} (只有 B 能用 K_B_AS 解开)
           解密得到  : {"B": "Bob", "K_AB": "kab#5", "N_A": 2559, "TICKET": "d2b565477e31e61a66753b4a..."}
           B 解密票据: {"A": "Alice", "K_AB": "kab#5", "T_issue": 1004200, "lifetime": 600}
           >>> Bob 拒绝本次会话: 时间戳超出窗口: |T_now-T_issue| = 600 s > window = 300 s
           >>> 时钟偏移导致合法请求被拒 —— 连接 Lecture 11/26 的时钟同步
  [3.4] 修复的代价 (b): 窗口内重放同一张新鲜票据 -> 重放缓存拒绝
           明文      : {"A": "Alice", "B": "Bob", "N_A": 4312}
           【旁白】AS 写进票据的内容: {"A": "Alice", "K_AB": "kab#6", "T_issue": 1003600, "lifetime": 600} (只有 B 能用 K_B_AS 解开)
           解密得到  : {"B": "Bob", "K_AB": "kab#6", "N_A": 4312, "TICKET": "8944a3d5ac9fcc1a4407e204..."}
           B 解密票据: {"A": "Alice", "K_AB": "kab#6", "T_issue": 1003600, "lifetime": 600}
           解密得到  : N_B = 2011      <- 只有刚拿到 K_AB 的 Alice 解得开
           解密得到  : N_B - 1 = 2010  <- 只有从票据拿到 K_AB 的主体答得出
           >>> Bob 认证通过 (peer=Alice)
           >>> 第一次(合法): ok=True, sessions=1
           >>> 第二次(重放): ok=False, 原因: 重放缓存命中: (client=Alice, T_issue=1003600) 已用过
           >>> 断言成立: 缓存命中, 窗口内的精确重放被拦截。
  【总结】时间戳方案把"防重放"外包给了时钟同步, 因此引入对 NTP 的依赖:
        窗口内的重放要靠 replay cache 兜底, 而时钟偏移/漂移会让合法请求被误拒。
==========================================================================
  [1] nonce 只对生成者新鲜: N_A 只保护 Alice <-> AS 这一段, Bob 看不到它,
  [2] 票据需要自己的新鲜度: 时间戳/序号必须能被接收者 (B) 独立验证 —— Denning-Sacco 的教训。
  [3] 时间戳换时钟依赖: 修复用时间戳换掉了"无新鲜度", 代价是把安全性外包给时钟同步 (NTP),
      窗口内的重放仍需 replay cache 兜底。
  【仅用于教学】以上原语均为玩具实现, 真实系统请使用成熟协议/库 (Kerberos v5 / TLS 1.3)。
==========================================================================
全部场景完成: 所有 assert 通过, 退出码 0

【代码做什么?】

  1. 教学原语与主体建模E(k, obj) 把对象 JSON 序列化后加上长度前缀,用 sha256(k \| iv \| counter) 生成的密钥流 XOR,再附 16 字节 HMAC 标签(因此篡改会被检测D() 返回 None 并打印告警);长期密钥由 sha256("K_A_AS") 这类可读标签派生,票据与 nonce 在转录里保持人类可读(kab#1N_A=7865),方便逐条读懂消息流。AliceASBobMallory 四个类各自维护显式状态(peerkabauthenticatedseen 重放缓存、skew 时钟偏移),Clock 提供可确定性推进的模拟时钟。
  2. sec0 长期密钥表:断言 K_A_AS 只被 Alice/AS 持有、K_B_AS 只被 Bob/AS 持有且两者不同——这是后面一切安全论证的假设基线
  3. sec1 正常五条消息流程:完整打印每条消息的发送方、接收方、明文/密文、解密结果,并断言 alice.kab == bob.kabalice.peer == "Bob"bob.peer == "Alice"、双方 authenticated == True
  4. sec2 Denning-Sacco 攻击(本代码的核心):先跑一次正常会话并”录制”下第 ③ 条消息(旧票据)与泄露的会话密钥 kab#2;随后让 Mallory 只带着这两样东西重放旧票据给 Bob。Bob 用 $K_{B,AS}$ 解开票据(合法)→ 发新挑战 $N_{B2}$ → Mallory 用泄露的旧密钥解开并回答 $N_{B2}-1$。断言 bob2.peer == "Alice"bob2.authenticated == Truealice2.kab is None——即”Bob 相信对方是 Alice,而真 Alice 从未参与”。
  5. sec3 修复与代价的实测:把票据改为携带 T_issuelifetime,Bob 增加两个检查(时间窗口 $\pm 300$ 秒、重放缓存 (client, T_issue))。依次实测四种情形:(3.1) 重放 3600 秒前的旧票据 ⇒ 被拒(时间戳超窗);(3.2) 当前时刻的新鲜票据 ⇒ 通过;(3.3) 把发起方时钟拨快 600 秒(票据本身完全合法)⇒ 合法请求被拒,这就是”防重放外包给时钟”的代价;(3.4) 窗口内重放同一张新鲜票据 ⇒ 被重放缓存拦下(说明时间戳不能单独工作,必须配重放缓存)。
  6. 最终总结:打印三条教训(nonce 只对生成者新鲜 / 票据需要接收者可独立验证的新鲜度 / 时间戳换来对 NTP 的依赖)。

【分布式机制透视】

  • 这是一个”人物角色 + 显式状态”的分布式模拟:没有 socket,但消息传递是点对点、显式命名发送方与接收方的msg(sender, receiver, ...)),并且每一步都打印”谁学了什么”——这正是安全协议分析的标准方法(符号模型 / 转录分析)。把 E/D 换成真实密码学库、把 msg 换成网络收发,这段代码就是协议的骨架。
  • 攻击者是”可控网络”而非”破解者”Mallory没有任何长期密钥(Principal.__init__(self.name, {})),它能成功完全依靠 Dolev-Yao 允许的两个动作——录制recorded)与重放(把旧密文当新消息发)。这精确复现了 25.3.4 的构造。
  • 时钟是一等公民Clock 与每个主体的 skew 让”时钟偏移导致的误拒”可以被稳定复现((3.3) 里 Alice 的 skew=600)。在真实系统中这就是 NTP 层面的问题(连接时钟同步一章):安全机制一旦依赖时间,时间源就进入了 TCB。
  • 重放缓存的工程细节在代码里是显式的字典 seen[(client, T_issue)]:真实系统需要考虑缓存容量(保留至少一个窗口)、单调性(窗口内时间戳不必严格递增)、以及 KDC 副本之间的缓存同步问题(跨 KDC 的票据可能被同一 Authenticator 攻击两个副本——这是 Kerberos 部署中的真实工程难题)。

【与理论的对应】

  • sec1 对应算法 25.3.4 的伪代码与”安全性论证的假设 (A1) 长期密钥保密 + (A3) nonce 不可预测”:代码里 new_nonce() 用 seeded PRNG 保证可复现,注释明确指出真实协议必须用 CSPRNG。
  • sec2 是 25.3.4 Denning-Sacco 反例的逐行实现:它验证了”把假设 (A2) 弱化为’攻击者掌握一次历史会话密钥’后,认证性质被推翻”,也验证了 25.2.11 的结论”nonce 只对生成者新鲜,票据本身没有新鲜度”。
  • sec3 对应 25.2.12 的 Kerberos 设计(时间戳 + 窗口 + 重放缓存)以及 25.2.16 第 ⑦ 条(时间与安全的交叉):(3.1) 与 (3.4) 证明修复有效,(3.3) 证明修复引入了新的假设——这就是”安全机制之间没有免费的午餐”的实测版本。

25.4.3 代码 3:Kerberos 完整流程模拟与三类攻击实测

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""CS425 分布式系统安全 · 代码示例 3:简化 Kerberos V5(KDC = AS + TGS)与三种攻击
【仅用于教学】这里的"加密"是玩具:sha256 计数器密钥流 XOR + HMAC-SHA256 认证标签,
既不是 AEAD,也不是 Kerberos 真正使用的 AES/DES,请勿用于任何真实场景。
运行:python3 c25_code3_kerberos.py      (CS425_QUICK=1 用更小的字典快跑)"""
import hashlib, hmac, itertools, json, os, random, time
random.seed(425)   # 固定种子让输出可复现;生产环境必须用 secrets 生成 IV/会话密钥
QUICK = bool(os.environ.get("CS425_QUICK"))
REALM = "CS425.EDU"
TGS_PRINCIPAL, SERVICE_PRINCIPAL = "krbtgt/" + REALM, "fileserver/hdfs-namenode"
T0 = 1700000000                  # 固定起始时刻,保证输出可复现
TGT_LIFETIME, TICKET_LIFETIME, SKEW = 8 * 3600, 300, 300   # TGT 8h;服务票据 5min;偏移窗口 5min
SALT, ITERATIONS, PASSWORD = b"CS425.EDU|alice|v1", 1000, "cs425"   # 教学用小迭代;真实系统 >= 100000

# ============================ 1. 玩具密码学【仅用于教学】 ============================
_iv_seq = itertools.count(1)

def _iv():
    """教学 IV(计数器 + 固定种子 PRNG)。真实系统必须用 os.urandom(8)/secrets.token_bytes(8)。"""
    return hashlib.sha256(b"iv|%d|%d" % (next(_iv_seq), random.getrandbits(64))).digest()[:8]

def _ks(key, iv, n):
    """计数器模式:把 sha256(key|iv|counter) 拼成长度 n 的密钥流。"""
    out = b""
    for c in itertools.count(0):
        out += hashlib.sha256(key + b"|" + iv + b"|" + str(c).encode()).digest()
        if len(out) >= n:
            return out[:n]

class Blob:
    """密文容器 iv|ct|tag:能嵌进 JSON(转十六进制),打印时只显示长度和前缀。"""
    __slots__ = ("iv", "ct", "tag")
    def __init__(self, iv, ct, tag):
        self.iv, self.ct, self.tag = iv, ct, tag
    @staticmethod
    def coerce(x):
        if isinstance(x, Blob):
            return x
        if isinstance(x, (list, tuple)) and len(x) == 3:
            parts = []
            for p in x:
                if isinstance(p, bytes):
                    parts.append(p)
                elif isinstance(p, str):
                    try:
                        parts.append(bytes.fromhex(p))
                    except ValueError:
                        return None
                else:
                    return None
            return Blob(parts[0], parts[1], parts[2])
        return None
    def to_list(self):
        return [self.iv.hex(), self.ct.hex(), self.tag.hex()]
    def __repr__(self):
        return "E[%dB iv=%s… tag=%s…]" % (len(self.ct), self.iv.hex()[:8], self.tag.hex()[:8])

def E(key, obj, iv=None):
    """教学版认证加密:ct = json(obj) XOR 密钥流;tag = HMAC(k, ct)。返回 Blob。"""
    pt = json.dumps(obj, sort_keys=True, ensure_ascii=False, default=lambda o: o.to_list()).encode()
    iv = _iv() if iv is None else iv
    ct = bytes(a ^ b for a, b in zip(pt, _ks(key, iv, len(pt))))
    return Blob(iv, ct, hmac.new(key, ct, hashlib.sha256).digest())   # 真实 AEAD 还会绑定 iv/aad

def D(key, blob):
    """先验 tag 再解密:密钥不对(tag 不匹配)就返回 None —— 这就是"认证"的来源。"""
    b = Blob.coerce(blob)
    if b is None or not hmac.compare_digest(hmac.new(key, b.ct, hashlib.sha256).digest(), b.tag):
        return None
    pt = bytes(a ^ b for a, b in zip(b.ct, _ks(key, b.iv, len(b.ct))))
    try:
        return json.loads(pt.decode())
    except (ValueError, UnicodeDecodeError):
        return None

def K(label):
    return hashlib.sha256(label.encode()).digest()

def session_key(label):
    return hashlib.sha256(label.encode() + b"|" + str(random.getrandbits(128)).encode()).digest()

def derive_client_key(pwd, salt=SALT, iters=ITERATIONS):
    """口令 -> 长期密钥 K_C(真实 Kerberos 同样由口令派生,所以离线字典攻击可行)。"""
    return hashlib.pbkdf2_hmac("sha256", pwd.encode(), salt, iters)

# ============================ 2. 时钟与打印工具 ============================
class Clock:
    """模拟全局时钟:所有时间戳都来自它,方便让各主体拥有不同的钟。"""
    def __init__(self, t0=T0, name="clock"):
        self._t, self.name = t0, name
    def now(self):
        return self._t
    def advance(self, sec):
        self._t += sec

def header(tag, title):
    print("=" * 74 + "\n" + tag + " " + title + "\n" + "=" * 74)

def short(x, n=116):
    if isinstance(x, str):
        s = x
    elif isinstance(x, Blob):
        s = repr(x)
    else:
        s = json.dumps(x, sort_keys=True, ensure_ascii=False, default=repr)
    return s if len(s) <= n else s[:n - 1] + "…"

def say(tag, src, dst, desc, msg=None):
    print("  [%s] %-9s -> %-9s %s" % (tag, src, dst, desc))
    if msg is not None:
        print("        %s" % short(msg))

def show(tag, label, obj):
    print("  [%s] 解密出 %s: %s" % (tag, label, short(obj)))

# ============================ 3. 参与方:AS / TGS / Server / Client ============================
def check_authenticator(skey, auth_blob, cache, clock, expect_client):
    """TGS 与 Server 共用的检查:能解密 + 时间戳在窗口内 + (client,timestamp) 未被重放。"""
    plain = D(skey, auth_blob)
    if plain is None:
        return None, "Authenticator 校验失败(tag 不匹配 ⇒ 对方不知道会话密钥)"
    now = clock.now()
    for pair, seen in list(cache.items()):        # 重放缓存:保留至少 2x 时钟偏移窗口
        if now - seen > 2 * SKEW:
            del cache[pair]
    delta = now - int(plain.get("t", 0))
    if abs(delta) > SKEW:
        return None, "时钟偏移过大 |Δt|=%ds > %ds(clock skew too great)" % (abs(delta), SKEW)
    if plain.get("c") != expect_client:
        return None, "Authenticator 中的客户端与票据不一致"
    if (plain["c"], plain["t"]) in cache:         # 键必须是 (client, timestamp)
        return None, "重放缓存命中 (%s, %d)(replay cache hit)" % (plain["c"], plain["t"])
    cache[(plain["c"], plain["t"])] = now
    return plain, "通过(时间戳在窗口内、且 (client,timestamp) 未出现过)"

class AS:
    """认证服务器:持有用户口令派生的密钥,签发 TGT。"""
    def __init__(self, clock, keytable):
        self.clock, self.keytable = clock, keytable
    def handle(self, req):
        now = self.clock.now()
        k_c_tgs = session_key("c-tgs|%s|%d" % (req["c"], now))
        tgt = {"c": req["c"], "tgs": req["tgs"], "t_start": now, "lifetime": req["lifetime"],
               "k_c_tgs": k_c_tgs.hex(), "addr": "10.0.0.7"}
        rep = {"k_c_tgs": k_c_tgs.hex(), "n1": req["n1"],
               "tgt": E(self.keytable[req["tgs"]], tgt)}       # TGT 只有 TGS 打得开
        return E(self.keytable[req["c"]], rep)                 # 用 K_C 保护 ⇒ 这就是 [4] 的攻击面

class TGS:
    """票据授权服务器:认 TGT 不认口令,签发服务票据。"""
    def __init__(self, clock, keytable, principal=TGS_PRINCIPAL):
        self.clock, self.keytable, self.principal = clock, keytable, principal
        self.replay_cache = {}
    def issue_ticket(self, client_name, k_c_s):
        return {"c": client_name, "s": SERVICE_PRINCIPAL, "t_start": self.clock.now(),
                "lifetime": TICKET_LIFETIME, "k_c_s": k_c_s.hex()}
    def handle(self, tgt_blob, auth_blob, n2):
        tgt = D(self.keytable[self.principal], tgt_blob)
        if tgt is None:
            return None, "TGT 解密/校验失败(tag 不匹配)"
        now = self.clock.now()
        if not (tgt["t_start"] - SKEW <= now <= tgt["t_start"] + tgt["lifetime"] + SKEW):
            return None, "TGT 不在有效期内"
        k_c_tgs = bytes.fromhex(tgt["k_c_tgs"])
        plain, why = check_authenticator(k_c_tgs, auth_blob, self.replay_cache, self.clock, tgt["c"])
        if plain is None:
            return None, why
        k_c_s = session_key("c-s|%s|%d" % (tgt["c"], now))
        ticket = E(self.keytable[SERVICE_PRINCIPAL], self.issue_ticket(tgt["c"], k_c_s))
        return E(k_c_tgs, {"k_c_s": k_c_s.hex(), "n2": n2, "ticket_s": ticket}), "OK"

class Server:
    """应用服务:只认识 KDC 给它的 K_S,不认识任何用户口令。"""
    def __init__(self, principal, key, clock):
        self.principal, self.key, self.clock = principal, key, clock
        self.replay_cache, self.authenticated = {}, set()
    def accept(self, ticket_blob, auth_blob):
        t = D(self.key, ticket_blob)
        if t is None:
            return None, "服务票据解密/校验失败(tag 不匹配)"
        now = self.clock.now()
        if now < t["t_start"] - SKEW:
            return None, "票据尚未生效"
        if now > t["t_start"] + t["lifetime"] + SKEW:
            return None, "票据已过期(now=%d, 截止=%d)" % (now, t["t_start"] + t["lifetime"])
        if t["s"] != self.principal:
            return None, "票据不是签发给本服务的"
        plain, why = check_authenticator(bytes.fromhex(t["k_c_s"]), auth_blob,
                                         self.replay_cache, self.clock, t["c"])
        if plain is None:
            return None, why
        self.authenticated.add(t["c"])
        return {"c": t["c"], "k_c_s": bytes.fromhex(t["k_c_s"]), "auth": plain}, why

class Client:
    def __init__(self, name, key, clock):
        self.name, self.key, self.clock = name, key, clock
        self._nonce = itertools.count(1000)
        self.k_c_tgs = self.k_c_s = self.ticket_s = None
        self.authenticated_to = set()
    def nonce(self):
        return next(self._nonce)

def make_world():
    """建一个 realm:客户端、KDC(AS+TGS)、服务各自持有自己的时钟。"""
    kt = {"alice@" + REALM: derive_client_key(PASSWORD),
          TGS_PRINCIPAL: K("KDC-master|" + TGS_PRINCIPAL),
          SERVICE_PRINCIPAL: K("KDC-master|" + SERVICE_PRINCIPAL)}
    cc, ck, cs = Clock(T0, "client"), Clock(T0, "kdc"), Clock(T0, "server")
    return {"keytable": kt, "as_": AS(ck, kt), "tgs": TGS(ck, kt), "client": Client("alice@" + REALM, kt["alice@" + REALM], cc),
            "server": Server(SERVICE_PRINCIPAL, kt[SERVICE_PRINCIPAL], cs),
            "clock_client": cc, "clock_kdc": ck, "clock_server": cs}

# ============================ 4. Kerberos 六步流程 ============================
def kerberos_flow(w, verbose=True):
    """跑完六步;任一步被拒就提前返回并记录原因。返回本次会话录下的报文(供攻击演示复用)。"""
    c, as_, tgs, srv, kt = w["client"], w["as_"], w["tgs"], w["server"], w["keytable"]
    R = {"ok": False, "why": ""}
    n1 = c.nonce()
    say("1", "C", "AS", "AS-REQ(明文){c, tgs, N1, lifetime}",
        {"c": c.name, "tgs": TGS_PRINCIPAL, "n1": n1, "lifetime": TGT_LIFETIME})
    R["as_rep"] = as_.handle({"c": c.name, "tgs": TGS_PRINCIPAL, "n1": n1, "lifetime": TGT_LIFETIME})
    say("2", "AS", "C", "AS-REP = E(K_C, {K_C_TGS, N1, TGT})", R["as_rep"])
    got = D(c.key, R["as_rep"])
    assert got is not None and got["n1"] == n1, "客户端无法用 K_C 解开 AS-REP 或 nonce 不符"
    assert D(kt[TGS_PRINCIPAL], got["tgt"]) is not None, "TGT 只能被 TGS 打开"
    show("2", "(仅为教学展示)AS-REP 明文", got)
    c.k_c_tgs = bytes.fromhex(got["k_c_tgs"])
    n2 = c.nonce()
    auth1 = E(c.k_c_tgs, {"c": c.name, "t": c.clock.now()})
    say("3", "C", "TGS", "TGS-REQ: TGT + Authenticator_1 = E(K_C_TGS, {c, T_now})",
        {"tgt": got["tgt"], "authenticator": auth1, "n2": n2})
    rep2, why = tgs.handle(got["tgt"], auth1, n2)
    if rep2 is None:
        R["why"] = "TGS 拒绝: " + why
        if verbose:
            print("  [3] TGS 拒绝本轮请求: %s" % why)
        return R
    say("4", "TGS", "C", "TGS-REP = E(K_C_TGS, {K_C_S, N2, ticket_S})", rep2)
    got4 = D(c.k_c_tgs, rep2)
    assert got4 is not None and got4["n2"] == n2, "客户端无法用 K_C_TGS 解开 TGS-REP 或 nonce 不符"
    show("4", "TGS-REP 明文(含服务票据)", got4)
    c.k_c_s, c.ticket_s = bytes.fromhex(got4["k_c_s"]), got4["ticket_s"]
    R["k_c_s"], R["ticket_s"] = c.k_c_s, c.ticket_s
    R["auth2_t"] = t2 = c.clock.now()
    R["auth2"] = auth2 = E(c.k_c_s, {"c": c.name, "t": t2})
    say("5", "C", "S", "AP-REQ: ticket_S + Authenticator_2 = E(K_C_S, {c, T_now})",
        {"ticket": c.ticket_s, "authenticator": auth2})
    info, why2 = srv.accept(c.ticket_s, auth2)
    if info is None:
        R["why"] = "Server 拒绝: " + why2
        if verbose:
            print("  [5] Server 拒绝本轮请求: %s" % why2)
        return R
    R["step6"] = rep6 = E(info["k_c_s"], {"t": t2 + 1})
    say("6", "S", "C", "AP-REP = E(K_C_S, {T_now + 1})", rep6)
    got6 = D(c.k_c_s, rep6)
    assert got6 is not None and got6["t"] == t2 + 1, "客户端无法用 K_C_S 解开 AP-REP 或时间戳不符"
    show("6", "时间戳 +1 ⇒ 证明对方真的持有 K_C_S", got6)
    c.authenticated_to.add(SERVICE_PRINCIPAL)
    R["ok"] = R["mutual"] = True
    return R

# ============================ 5. 场景 [0] 密钥表 / [1] 正常流程 ============================
def scenario_keytable(kt):
    header("[0]", "密钥表:KDC 与各主体共享的长期密钥【仅用于教学】")
    for name in sorted(kt):
        print("  %-28s K = %s… (%d B)" % (name, kt[name].hex()[:24], len(kt[name])))
    print("  注意:alice 的 K_C 不是随机数,而是口令 %r 经 PBKDF2(salt=%r, iter=%d) 派生,\n"
          "        真实系统 >= 100000 次迭代;这也正是 [4] 离线口令猜测的攻击面"
          % (PASSWORD, SALT, ITERATIONS))

def scenario_normal(w):
    header("[1]", "Kerberos 正常六步流程(KDC = AS + TGS)")
    R = kerberos_flow(w)
    assert R["ok"], R["why"]
    assert w["client"].k_c_s is not None and D(w["client"].k_c_s, R["step6"]) is not None
    assert w["server"].authenticated == {"alice@" + REALM}, "服务端未认为客户端已认证"
    assert SERVICE_PRINCIPAL in w["client"].authenticated_to, "客户端未认为服务端已认证"
    cache = ["(%s, %d)@seen=%d" % (k[0], k[1], v) for k, v in sorted(w["server"].replay_cache.items())]
    print("  服务端重放缓存: %s" % ", ".join(cache))
    print("  ==> 双向认证成功(mutual authentication)")
    return R

# ============================ 6. 三种攻击 ============================
def scenario_replay(w, R):
    header("[2]", "攻击 1 —— 票据重放(ticket replay)")
    srv = w["server"]
    for name in ("clock_client", "clock_kdc", "clock_server"):
        w[name].advance(120)                     # 票据寿命 5 分钟,重放时仍未过期
    tkt = D(srv.key, R["ticket_s"])
    assert tkt is not None
    print("  120 秒后,Mallory 把 [5] 录下的 ticket_S + Authenticator_2 原样重放:")
    print("    票据窗口 [%d, %d],服务端 now = %d ⇒ 票据本身完全有效(关键:票据不防重放)"
          % (tkt["t_start"], tkt["t_start"] + tkt["lifetime"], srv.clock.now()))
    assert srv.clock.now() <= tkt["t_start"] + tkt["lifetime"], "重放时票据必须仍在有效期内"
    info, why = srv.accept(R["ticket_s"], R["auth2"])
    assert info is None and "重放缓存" in why, why
    print("  (a) 原样重放票据 + 录下的 Authenticator ⇒ 被拒: %s" % why)
    forged = E(K("mallory-guess"), {"c": "alice@" + REALM, "t": srv.clock.now()})
    info2, why2 = srv.accept(R["ticket_s"], forged)
    assert info2 is None and "tag 不匹配" in why2, why2
    print("  (b) Mallory 伪造一个新时间戳的 Authenticator ⇒ 被拒: %s" % why2)
    print("      D(K_C_S, forged) 返回 None:她不知道 K_C_S,就写不出能通过 tag 校验的密文")
    print("  ==> 票据可被窃听重放;真正防重放的是 Authenticator 的时间戳 + 服务端重放缓存,\n"
          "      而 Authenticator 必须用会话密钥加密 ⇒ 攻击者拿不到 K_C_S 就伪造不出来。\n"
          "  [设计细节] 重放缓存必须以 (client, timestamp) 为键,并保留 >= 2×时钟偏移窗口(%ds):\n"
          "      若只按 client 记为'已见',或按秒粒度去重,同一秒内的正常认证会被误判为重放。" % (2 * SKEW))

def scenario_clock_skew():
    header("[3]", "攻击 2 —— 时钟偏移(clock skew too great)")
    print("  客户端时钟快 600 s(> %d s 窗口),KDC/Server 时钟不动:" % SKEW)
    w = make_world()
    w["clock_client"].advance(600)
    R = kerberos_flow(w, verbose=False)
    assert not R["ok"] and "TGS" in R["why"] and "时钟偏移" in R["why"], R["why"]
    print("  (a) TGS 拒绝: %s" % R["why"])
    got = D(w["client"].key, R["as_rep"])
    tgt = D(w["keytable"][TGS_PRINCIPAL], got["tgt"])
    assert tgt["t_start"] <= w["clock_kdc"].now() <= tgt["t_start"] + tgt["lifetime"]
    print("      注意:AS 照旧发了 TGT,且 TGT 仍在有效期内 —— 失败的唯一原因就是时钟不同步")
    ks = session_key("skew-demo|%s" % w["client"].name)
    ticket = E(w["tgs"].keytable[SERVICE_PRINCIPAL], w["tgs"].issue_ticket(w["client"].name, ks))
    auth = E(ks, {"c": w["client"].name, "t": w["client"].clock.now()})   # 快 600 s 的时间戳
    info, why = w["server"].accept(ticket, auth)
    assert info is None and "时钟偏移" in why, why
    print("  (b) 同一张完全合法的服务票据交给 Server ⇒ 也被拒: %s" % why)
    w2 = make_world()
    w2["clock_server"].advance(600)
    R2 = kerberos_flow(w2, verbose=False)
    assert not R2["ok"] and "Server" in R2["why"] and "时钟偏移" in R2["why"], R2["why"]
    print("  (c) 反向:Server 时钟快 600 s(KDC/客户端不动)⇒ 前 4 步成功,第 5 步被拒: %s" % R2["why"])
    print("  ==> Kerberos 把防重放外包给了时钟同步:既要 NTP,又只能松同步(默认 5 分钟)。\n"
          "      攻击者若能控制/欺骗时间源(NTP 欺骗),就能把重放窗口放大到偏移量大小。")

def build_dictionary():
    """教学字典:常见弱口令 + 规则变体,总量约 300 条。"""
    base = ["123456", "password", "12345678", "qwerty", "abc123", "monkey", "letmein", "dragon",
            "trustno1", "iloveyou", "admin", "welcome", "master", "sunshine", "football",
            "cs425", "cs425edu", "kerberos", "superman", "batman"]
    dic = list(base)
    for i in range(1, 20 if QUICK else 140):
        dic += ["cs425%03d" % i, base[i % len(base)] + str(i)]
    dic.append(PASSWORD)
    random.shuffle(dic)
    return dic

def scenario_password_crack(R):
    header("[4]", "攻击 3 —— 口令猜测(AS-REP 离线字典攻击 / AS-REP roasting)")
    blob = R["as_rep"]
    print("  Mallory 录下 [2] 的 AS-REP: %r" % blob)
    print("  它由 K_C 加密,而 K_C = PBKDF2(alice 的口令) ⇒ 攻击者可以完全离线地逐条试口令")
    dic = build_dictionary()
    print("  字典规模 = %d 条(真实攻击用 rockyou/Weakpass 等上亿条);KDF = pbkdf2_hmac('sha256', pwd, %r, %d)"
          % (len(dic), SALT, ITERATIONS))
    tries, found = 0, None
    t_start = time.perf_counter()
    for cand in dic:
        tries += 1
        if D(derive_client_key(cand), blob) is not None:      # tag 校验通过 ⇒ 口令命中
            found = cand
            break
    elapsed = time.perf_counter() - t_start
    assert found == PASSWORD, "字典攻击应当能恢复出口令"
    print("  命中:口令 = %r,尝试 %d/%d 次,耗时 %.4f s(%.3f ms/次)"
          % (found, tries, len(dic), elapsed, 1000.0 * elapsed / max(tries, 1)))
    assert D(derive_client_key("Zq7!not-in-dictionary"), blob) is None
    print("  候选 \"Zq7!not-in-dictionary\" 不在字典里 ⇒ D 返回 None:攻击完全依赖字典质量")
    t1 = time.perf_counter()
    for _ in range(20):
        derive_client_key("benchmark")
    per = (time.perf_counter() - t1) / 20
    print("  单次 KDF 实测 %.3f ms ⇒ 1e6 条字典单核约 %.1f s;再多核并行还能按核数线性加速"
          % (per * 1000, per * 1e6))
    real = per * 100                                          # 真实系统迭代次数 >= 100k
    print("  若迭代次数提高到 100000(本演示的 ×100):单次 %.1f ms,1e6 条字典单核约 %.1f 小时"
          % (real * 1000, real * 1e6 / 3600))
    print("  ==> 该攻击的成本 ∝ 口令熵 × KDF 迭代次数:高熵口令 + 高迭代 KDF 才让离线猜测不可行。")

def scenario_summary():
    header("[5]", "总结")
    print("  Kerberos 的三条安全性质:\n"
          "   1) 票据不可伪造:票据由 KDC 用只有目标服务知道的 K_S 加密/认证,客户端无法自造或篡改。\n"
          "   2) Authenticator 防重放:会话密钥加密的时间戳 + (client,timestamp) 重放缓存。\n"
          "   3) 依赖松同步时钟:偏移窗口默认 300 s,超窗即拒(因此需要 NTP,且它是安全依赖项)。\n"
          "  三条工程代价:\n"
          "   1) KDC 是单点:AS/TGS 挂了整个 realm 就无法登录,必须高可用 + 强保护。\n"
          "   2) 时钟依赖:时间源被欺骗或漂移会直接造成拒绝服务,或放大重放窗口([3])。\n"
          "   3) 口令猜测面:AS-REP 可离线爆破,弱口令 + 低迭代 KDF 基本等于明文([4])。")

if __name__ == "__main__":
    world = make_world()
    scenario_keytable(world["keytable"])
    records = scenario_normal(world)
    scenario_replay(world, records)
    scenario_clock_skew()
    scenario_password_crack(records)
    scenario_summary()
    print("\n全部断言通过(exit code 0)。【仅用于教学】")

实际运行输出(节选,可复现;省略了逐条消息的 JSON/十六进制明细行)

==========================================================================
[0] 密钥表:KDC 与各主体共享的长期密钥【仅用于教学】
==========================================================================
  alice@CS425.EDU              K = 3380068a44809f5ef3393237… (32 B)
  fileserver/hdfs-namenode     K = 874a5b8853b95dd72af9be45… (32 B)
  krbtgt/CS425.EDU             K = 0b68ffcdfed0f582ffd7ba41… (32 B)
  注意:alice 的 K_C 不是随机数,而是口令 'cs425' 经 PBKDF2(salt=b'CS425.EDU|alice|v1', iter=1000) 派生,
        真实系统 >= 100000 次迭代;这也正是 [4] 离线口令猜测的攻击面
==========================================================================
[1] Kerberos 正常六步流程(KDC = AS + TGS)
==========================================================================
  [1] C         -> AS        AS-REQ(明文){c, tgs, N1, lifetime}
  [2] AS        -> C         AS-REP = E(K_C, {K_C_TGS, N1, TGT})
  [3] C         -> TGS       TGS-REQ: TGT + Authenticator_1 = E(K_C_TGS, {c, T_now})
  服务端重放缓存: (alice@CS425.EDU, 1700000000)@seen=1700000000
  ==> 双向认证成功(mutual authentication)
==========================================================================
[2] 攻击 1 —— 票据重放(ticket replay)
==========================================================================
  120 秒后,Mallory 把 [5] 录下的 ticket_S + Authenticator_2 原样重放:
  (a) 原样重放票据 + 录下的 Authenticator ⇒ 被拒: 重放缓存命中 (alice@CS425.EDU, 1700000000)(replay cache hit)
  (b) Mallory 伪造一个新时间戳的 Authenticator ⇒ 被拒: Authenticator 校验失败(tag 不匹配 ⇒ 对方不知道会话密钥)
  ==> 票据可被窃听重放;真正防重放的是 Authenticator 的时间戳 + 服务端重放缓存,
  [设计细节] 重放缓存必须以 (client, timestamp) 为键,并保留 >= 2×时钟偏移窗口(600s):
==========================================================================
[3] 攻击 2 —— 时钟偏移(clock skew too great)
==========================================================================
  客户端时钟快 600 s(> 300 s 窗口),KDC/Server 时钟不动:
  [1] C         -> AS        AS-REQ(明文){c, tgs, N1, lifetime}
  [2] AS        -> C         AS-REP = E(K_C, {K_C_TGS, N1, TGT})
  [3] C         -> TGS       TGS-REQ: TGT + Authenticator_1 = E(K_C_TGS, {c, T_now})
  (a) TGS 拒绝: TGS 拒绝: 时钟偏移过大 |Δt|=600s > 300s(clock skew too great)
  (b) 同一张完全合法的服务票据交给 Server ⇒ 也被拒: 时钟偏移过大 |Δt|=600s > 300s(clock skew too great)
  [1] C         -> AS        AS-REQ(明文){c, tgs, N1, lifetime}
  [2] AS        -> C         AS-REP = E(K_C, {K_C_TGS, N1, TGT})
  [3] C         -> TGS       TGS-REQ: TGT + Authenticator_1 = E(K_C_TGS, {c, T_now})
  (c) 反向:Server 时钟快 600 s(KDC/客户端不动)⇒ 前 4 步成功,第 5 步被拒: Server 拒绝: 时钟偏移过大 |Δt|=600s > 300s(clock skew too great)
  ==> Kerberos 把防重放外包给了时钟同步:既要 NTP,又只能松同步(默认 5 分钟)。
==========================================================================
[4] 攻击 3 —— 口令猜测(AS-REP 离线字典攻击 / AS-REP roasting)
==========================================================================
  Mallory 录下 [2] 的 AS-REP: E[576B iv=8cbe0308… tag=d0dcfa08…]
  它由 K_C 加密,而 K_C = PBKDF2(alice 的口令) ⇒ 攻击者可以完全离线地逐条试口令
  字典规模 = 299 条(真实攻击用 rockyou/Weakpass 等上亿条);KDF = pbkdf2_hmac('sha256', pwd, b'CS425.EDU|alice|v1', 1000)
  命中:口令 = 'cs425',尝试 215/299 次,耗时 0.0426 s(0.198 ms/次)
  候选 "Zq7!not-in-dictionary" 不在字典里 ⇒ D 返回 None:攻击完全依赖字典质量
  单次 KDF 实测 0.193 ms ⇒ 1e6 条字典单核约 193.4 s;再多核并行还能按核数线性加速
  若迭代次数提高到 100000(本演示的 ×100):单次 19.3 ms,1e6 条字典单核约 5.4 小时
  ==> 该攻击的成本 ∝ 口令熵 × KDF 迭代次数:高熵口令 + 高迭代 KDF 才让离线猜测不可行。
==========================================================================
[5] 总结
==========================================================================
  Kerberos 的三条安全性质:
   1) 票据不可伪造:票据由 KDC 用只有目标服务知道的 K_S 加密/认证,客户端无法自造或篡改。
   2) Authenticator 防重放:会话密钥加密的时间戳 + (client,timestamp) 重放缓存。
   3) 依赖松同步时钟:偏移窗口默认 300 s,超窗即拒(因此需要 NTP,且它是安全依赖项)。
  三条工程代价:
   1) KDC 是单点:AS/TGS 挂了整个 realm 就无法登录,必须高可用 + 强保护。
   2) 时钟依赖:时间源被欺骗或漂移会直接造成拒绝服务,或放大重放窗口([3])。
   3) 口令猜测面:AS-REP 可离线爆破,弱口令 + 低迭代 KDF 基本等于明文([4])。
全部断言通过(exit code 0)。【仅用于教学】

【代码做什么?】

  1. KDC 与主体建模ASTGSServerClient 四类对象;票据格式与真实 Kerberos 同构——TGT = E(K_TGS, {c, tgs, T_start, lifetime, K_C_TGS})TICKET_S = E(K_S, {...})Authenticator = E(K_session, {c, T_now}),其中 E 是”密钥流 XOR + HMAC 标签”的教学 AEAD(篡改即解密失败)。Server 维护 seen[(client, timestamp)] 重放缓存与窗口 $\Delta = 300$ 秒,Clock 提供可推进的模拟时间。
  2. [0] 密钥表:打印三个主体的长期密钥,并特别指出 Alice 的 K_C 不是随机数,而是口令 cs425 经 PBKDF2(本演示 1000 次迭代)派生的——这正是 [4] 离线口令猜测的攻击面。
  3. [1] 正常六步流程:逐条打印 AS-REQ/AS-REP、TGS-REQ/TGS-REP、AP-REQ/AP-REP,最后 [6] 的服务端回包中 {T_now + 1} 解密成功 ⇒ 断言双向认证完成、双方持有同一 $K_{C,S}$。
  4. [2] 攻击 1:票据重放:把时钟推进 120 秒(此时票据仍在有效期内,用以说明”票据本身不防重放”)。(a) 原样重放票据 + 录下的 Authenticator ⇒ 被重放缓存拒绝((alice, 1700000000) 已见);(b) Mallory 尝试伪造一个新时间戳的 Authenticator ⇒ 标签校验失败,因为她不知道 $K_{C,S}$。并打印一条真实的工程细节:重放缓存必须以 (client, timestamp) 为键并至少保留 $2\Delta = 600$ 秒,否则同一秒内的正常认证会被误判为重放。
  5. [3] 攻击 2:时钟偏移:把客户端时钟拨快 600 秒($>300$ 秒窗口)⇒ (a) TGS 拒绝、(b) 同一张完全合法的服务票据交给 Server 也被拒、(c) 反向(服务器快 600 秒)也失败。打印”KDC 照旧签发了 TGT 且 TGT 有效——失败的唯一原因就是时钟不同步”。
  6. [4] 攻击 3:AS-REP 离线口令猜测:Mallory 录下 [2] 的 AS-REP(由 $K_C$ 加密),用 299 条字典逐条派生 $K_C^{\prime}$ 并尝试解密 ⇒ 实测 215 次命中口令 cs425,打印单次 KDF 耗时(0.194 ms)与规模外推($10^6$ 条字典单核约 194 秒;若迭代次数提高 100 倍 ⇒ 单核约 5.4 小时);并验证”不在字典里的口令无法破解”,说明攻击成本 ∝ 口令熵 × KDF 迭代次数
  7. [5] 总结:打印三条安全性质与三条工程代价(KDC 单点、时钟依赖、口令猜测面)。

【分布式机制透视】

  • 三个”信任域”在代码里是显式的:客户端不能解密服务票据(票据只用 $K_S$ 保护)、服务器不能阅读 TGT(只用 $K_{TGS}$ 保护)、KDC 不保存会话状态(票据自包含)。这正是 Kerberos 可扩展的关键:信任被压缩成”每个主体与 KDC 共享一把密钥”这一条边,而不是 $O(N^2)$ 的全互联。
  • 时间在这段代码里是外部输入Clock + 每个主体的 skew),因此”时钟偏移 ⇒ 合法请求被拒”可以被确定性地复现。真实系统中,这一角色由 NTP/NTS 承担,而它同时也是攻击面(NTP 欺骗直接放大重放窗口)。
  • 重放缓存是”分布式状态”:单 KDC 时它只是一个字典;部署多个 KDC 副本时,必须考虑缓存是否需要跨副本共享(否则攻击者可以对不同副本重放同一个 Authenticator)、保留时长与内存占用、以及重启后缓存丢失导致的重放窗口。这是”安全机制的工程实现远比协议本身复杂”的极好例子。

【与理论的对应】

  • [1] 对应算法 25.3.5 的伪代码与”票据不可伪造”的安全论证(Safety-1)。
  • [2] 验证了 25.3.5 的 Safety-2:票据可被重放,防重放必须依赖 Authenticator 的会话密钥 + 时间戳 + 重放缓存三条合起来。
  • [3] 验证了 25.3.5 的 Safety-3(弱点)与 25.2.16 第 ⑦ 条:$\vert T_{server} - T_{auth}\vert \le \Delta$ 这一行代码把系统安全性外包给了时钟同步
  • [4] 验证了 25.3.5 的 Safety-4,也定量说明了 25.2.7 第 3 条”为什么口令必须加盐 + 慢哈希”:在同一台机器上,把迭代次数从 $10^3$ 提到 $10^5$ 让攻击成本上升两个数量级。

25.5 性能与可扩展性分析

讲义的第四步是”在常态(没有攻击)下度量机制对性能的影响“。这一节把它量化:先给各类原语的性能数量级,再算协议层(TLS、Kerberos)的延迟成本,最后算”密钥管理”和”拜占庭容错”的可扩展性账单。

25.5.1 各密码学原语的性能数量级(单核、现代 x86 服务器,数量级估计)

原语典型吞吐 / 速率相对成本依赖的硬件特性主要用途
AES-128/256-GCM(有 AES-NI)$10^9\sim10^{10}$ B/s(数 GB/s)1×(基准)AES-NI/VAES批量数据加密(TLS 记录层、磁盘加密)
ChaCha20-Poly1305$10^9$ B/s 量级约 1–2×无(纯软件,移动端友好)无 AES 加速的设备、TLS 1.3 备选套件
SHA-256 哈希$10^9$ B/s 量级(有 SHA-NI 更高)约 1–2×SHA 扩展指令完整性摘要、KDF、口令哈希(单轮)
HMAC-SHA256$5\times10^8\sim10^9$ B/s约 2–4×(两次哈希)同上消息认证(TLS 记录、票据)
PBKDF2-HMAC-SHA256($2\times10^5$ 次迭代)约 $1/(4\times10^{-2})$ ≈ 25 次/秒/核$10^5$ 倍慢(故意)口令派生:慢是特性不是缺陷
bcrypt / Argon2id单次 10–300 ms(可调)同上(故意)内存硬度(Argon2)口令存储(抗 GPU/ASIC 爆破)
RSA-2048 加密(OAEP)$10^3\sim10^4$ 次/秒$10^6$ 倍大数模幂密钥封装(混合加密)、兼容性场景
RSA-2048 签名(PSS)$10^3$ 次/秒量级更慢(指数为 $d$)同上证书签发、代码签名(低频)
RSA-2048 验签$10^4\sim10^5$ 次/秒快($e=65537$ 只有 2 个 1 比特)同上证书链验证(客户端常见瓶颈)
ECDSA P-256 签名 / 验签$10^4\sim10^5$ 次/秒比 RSA 快 1–2 个数量级TLS 证书、移动端
Ed25519 签名 / 验签$10^4\sim10^5$ 次/秒同上,且确定性现代首选签名
X25519 ECDHE(一次密钥交换)$10^4\sim10^5$ 次/秒一次标量乘TLS/Signal 的前向保密密钥交换
DH-2048(有限域)$10^3\sim10^4$ 次/秒比 X25519 慢约一个数量级传统密钥交换

为什么实际协议必须混合加密(用数量级说话):若用 RSA-2048 加密 1 GB 数据,每块最多约 190–245 字节 ⇒ 约 440 万次模幂;按 $10^3$ 次/秒算需要 约 70 分钟。同样 1 GB 用 AES-GCM(3 GB/s)只需 约 0.3 秒——相差约 $10^4$ 倍。因此公钥只用于”传递 32 字节的会话密钥”(一次操作),对称加密承担全部数据搬运。这不是优化,而是唯一可行的架构

25.5.2 TLS 握手的延迟成本与 0-RTT 的重放风险

版本/模式握手 RTT(TCP 之上)首次连接总 RTT(含 TCP)证书验证前向保密重放风险
TLS 1.2(RSA 密钥交换)2 RTT3 RTT无(但不抗”事后解密”)
TLS 1.2(ECDHE + 会话恢复)1–2 RTT2–3 RTT恢复时可省
TLS 1.3(完整握手)1 RTT2 RTT强制
TLS 1.3(PSK 恢复)0 RTT(应用数据随第一个飞行发出)1 RTT(TCP)可省(依赖票据)有条件(psk 前向保密取决于实现)有!早期数据可被重放
QUIC(TLS 1.3 内嵌)1 RTT / 0 RTT1 RTT / 0 RTT强制同 0-RTT
  • 含义:跨洲 RTT 约 150 ms 时,TLS 1.2 首次连接要付约 450 ms,TLS 1.3 约 300 ms,恢复 + 0-RTT 可降到约 150 ms——在页面上有成百上千个连接时,这是秒级的差别。这就是 0-RTT 存在的理由。
  • 0-RTT 的代价必须写清:早期数据在服务器看到任何新鲜度之前就被处理,因此攻击者可以原样重放它(对服务器而言,两次请求携带的密文与票据都可以合法)。正确用法:只对幂等请求启用(GET、幂等 API)、服务端对 0-RTT 票据做单次性标记或时间窗口限制、绝不把”转账/下单/删除”这类非幂等操作放进早期数据。这是一个”性能换安全”的教科书级权衡
  • 会话复用的收益与风险:会话票据(session ticket)省掉证书验证与 ECDHE 的往返,但票据的加密密钥成了新的长期秘密——若它不轮换,泄露后可解密历史恢复流量(破坏前向保密)。因此现代实践要求定期轮换票据密钥并保持前向保密

25.5.3 认证的开销:Kerberos 的 RTT、单点与时钟依赖

  • 延迟账单:首次登录 3 个 RTT 量级(AS 交换 + TGS 交换 + AP 交换);TGT 缓存后每次访问服务 2 个 RTT 量级;若启用双向认证再多半个 RTT。对比直接共享密钥认证(1.5 个 RTT),Kerberos 用约 2 倍往返换来了免预共享单点登录
  • KDC 的可扩展性瓶颈:(i) 每用户每次登录都要做一次口令派生(PBKDF2/Argon2,故意慢,10–300 ms) ⇒ KDC 的 CPU 是登录风暴时的第一瓶颈;(ii) 票据签发要额外加密与签名;(iii) KDC 是单点信任与单点故障,副本化带来密钥数据库同步与主密钥保护的难题(副本越多,攻击面越大——因为它持有全部长期密钥的加密形式)。
  • 时钟依赖的运维成本:窗口 $\Delta = 5$ 分钟意味着必须有一个健康的 NTP/时间服务;时间服务异常会让整个 realm 的认证失败(可用性事件),而时间服务被攻击者控制则直接削弱防重放(安全性事件)。因此时间源必须纳入 TCB 并做监控与认证(NTS)。
  • 口令策略的性能悖论:为了抵抗离线爆破,KDF 迭代次数要;为了 KDC 能扛住登录峰值,迭代次数又要。解决方案通常是加大硬件与缓存(票据复用、SSO 会话)、分级策略(高价值主体用更强的 KDF 与 MFA),而不是降低 KDF 强度。

25.5.4 密钥管理的可扩展性

方案密钥总数$N=10^3$$N=10^6$吊销一个主体主要瓶颈
两两共享对称密钥$\frac{N(N-1)}{2}$49.95 万$\approx5\times10^{11}$需要重建整组密钥存储、分发、吊销全部不可行
公钥体系(每人一对)$N$ 对$10^3$ 对$10^6$ 对吊销证书(CRL/OCSP)公钥真实性必须靠 PKI
对称 + KDC(Kerberos)$N$ 个长期密钥 + 每会话 1 把$10^3$$10^6$从 KDC 删除主体(已发票据到期失效)KDC 单点与容量
公钥 + PKI(TLS 生态)$N$ 对 + 每会话临时密钥$10^3$ 对$10^6$ 对吊销依赖 CA 的 CRL/OCSP(软失败)信任根数量(约 100–150)与 CA 的运维质量
Web of Trust(PGP)每对至少一条签名边撤销声明传播慢不扩展、无可信 UI
  • 关键在于”复杂度从密钥数搬到了信任管理”:公钥把 $O(N^2)$ 降到 $O(N)$,但引入了”我必须相信 CA”这个集中信任(信任根只有百来个,任何一个被攻破都可能伪造任意站点);对称 + KDC 把 $O(N^2)$ 降到 $O(N)$,但引入了 KDC 单点时钟依赖。两者都不是”免费的可扩展性”,只是把成本从”数量”换成了”信任的集中程度”。

25.5.5 拜占庭容错的成本:$3f+1$ 与 $O(N^2)$ 消息

容错模型容忍 $f$ 个故障所需副本数每请求消息复杂度延迟(轮次)是否需要密码学典型系统
崩溃容错(CFT,如 Raft/Paxos)$2f+1$$O(N)$1–2否(认证可选)etcd、ZooKeeper、Spanner
拜占庭容错(口头消息)$3f+1$$O(N^2)$(PBFT 三阶段)3(pre-prepare/prepare/commit)PBFT、Tendermint(许可链)
拜占庭容错(带签名消息$f+2$(LSP 的 SM 算法)$O(N^2)$ 或 $O(N\cdot f)$(视协议)$f+1$是(签名)学术协议、部分许可链优化
无需许可共识(PoW/PoS)由算力/质押决定(无固定 $N$)全网广播概率最终性(分钟级)Bitcoin、Ethereum
  • 账单:容忍 1 个拜占庭故障,CFT 只需 3 副本且每请求 $O(N)$ 消息;BFT 需要 4 副本且每请求 $O(N^2)$ 消息($N=4$ 时是 16 量级的消息 × 3 阶段)。副本数 +1、消息数从 $N$ 变 $N^2$、延迟多一轮,这就是”从崩溃容错升级到拜占庭容错”的价格;若接受签名假设,副本数可降到 $f+2$(代价是签名生成/验证的 CPU 与密钥管理,以及永远存在的”签名被攻破则一切归零”的风险)。
  • 与安全机制的叠加:BFT 协议里的每条消息都要认证(MAC 或签名),因此密码学开销是乘在 $O(N^2)$ 消息量上的——$N=100$ 的 BFT 集群每请求要认证约 $10^4$ 条消息,这正是 BFT 难以扩展到大规模 WAN 部署的根本原因,也是”公链用经济激励取代身份认证”这一路线被提出的背景。

25.5.6 安全与性能的权衡:缓解手段及其安全代价

缓解手段带来的性能收益引入的安全代价(必须写进设计文档)
AES-NI / 硬件加速、密码协处理器、HSM对称加密从数百 MB/s 提到数 GB/sHSM 成为关键路径单点;密钥不可导出会限制应急响应
会话复用 / 票据 / 长连接池消除重复握手(1–2 RTT + 证书验证)票据密钥成了长期秘密,需轮换;会话固定(session fixation)风险
0-RTT / QUIC 早期数据省去 1 RTT(跨洲约 150 ms)可重放:只能用于幂等操作
批量化与流水线(把多次签名合并、批量验签)提高吞吐、摊薄固定开销批处理引入延迟与”部分失败”处理复杂度;批内错误可能放大
TLS 在边界终止 + 内部明文省 CPU、便于观测与缓存内网重新变成”可信”(横向移动风险);违背端到端原则
服务网格自动 mTLS无侵入地让内部流量加密与认证sidecar 增加延迟(每个 RTT 数次代理)与资源开销;证书轮换依赖控制面可用性
降低认证强度(关掉校验、拉长有效期、放宽时钟窗口)立竿见影的可用性提升这是一步步走向”没有安全”的标准路径——每一次让步都应记录为显式的风险接受

结论:安全机制的成本必须在常态(无攻击)下度量(讲义第四步),因为那是 99.99% 的时间;但在攻击发生时,没有任何性能指标值得拿来交换安全性。因此正确的工程姿势是:把安全开销摊到架构上(会话复用、硬件加速、分层),而不是把安全强度降下来

25.6 关键要点

  1. 分布式安全的根本困难是”没有物理边界、没有可信的中央权威”:因此不能靠”位置/内网”判断可信,只能靠密码学证据(密钥、签名、票据)来重建信任;任何”内网可信”的假设都是把攻击者的工作量降到零。
  2. “加密”只解决机密性:完整性要 MAC/AEAD、身份要认证协议与证书、新鲜度要 nonce/时间戳/序列号、授权要访问控制、可追责要审计与签名——这五件事没有一件能靠”加个密”顺带解决
  3. 真正的难点是密钥分发与信任的集中:对称密钥是 $O(N^2)$($N=1000$ 需 49.95 万把),公钥把它降到 $O(N)$ 对,但代价是把信任集中到少数 CA/KDC——DigiNotar 与 KDC 单点都是这份账单的体现。
  4. 混合加密不是优化而是唯一可行架构:公钥($10^3\sim10^5$ 次/秒)只用来传 32 字节的会话密钥,对称 AEAD(GB/s)搬全部数据,两者相差 $10^4$ 倍以上。
  5. 认证协议的安全性几乎总是落在”新鲜度”这一行代码上:nonce 只能让生成者确认新鲜;票据/令牌自身没有新鲜度就会被重放(Denning-Sacco);用时间戳换新鲜度的代价是依赖时钟同步($\pm 5$ 分钟窗口),因此时间源(NTP)必须被当作 TCB 的一部分来保护
  6. 安全是”假设的组合”,不是某个机制:签名把拜占庭容错从 $3f+1$ 降到 $f+2$(用密码学假设换副本数),但一旦签名/随机数/哈希被攻破,所有界同时失效。所以每一个安全声明都必须写清:信任谁、依赖什么假设、假设失效时会怎样

本章黄金法则:分布式安全的核心困难是”没有可信的边界与中央权威“——因此需要密码学提供不可伪造的证据(签名、MAC),需要 PKI 提供公钥的真实性(但把信任集中到 CA),需要认证协议提供身份的确认(但都依赖 nonce / 时间戳 / 时钟同步)。安全不是某个机制,而是贯穿每一层的假设组合。

25.7 常见陷阱与注意事项

  1. 以为”加密了”就等于”安全了”。 为什么错:加密只提供机密性,密文仍可被篡改、重放、延迟、丢弃;CBC/CTR 的密文可以被主动修改出可预测的明文变化。正确做法:用 AEAD(AES-GCM、ChaCha20-Poly1305)或”先加密后 MAC”,并让认证覆盖所有语义字段(含协议版本、目标身份、AAD)。
  2. 禁用证书验证(verify=Falsecurl -k、自签证书塞进信任库)。 为什么错:这让 TLS 退化为”能加密但人人可冒充”,MITM 一步到位(见 25.2.9 的图)。正确做法:始终验证链、域名(SAN)、有效期与用途;测试环境用私有 CA 并正确分发根证书,而不是关掉校验。
  3. 把 MAC 当签名用,或把签名当 MAC 用。 为什么错:MAC 由共享密钥生成,争议时无法归属(法官无法裁决);签名用私钥生成,但性能与密钥管理成本高,且大量高频消息签名会拖垮吞吐。正确做法:会话内数据用 MAC/AEAD,跨组织、跨时间、需要归属的证据用签名(25.4 代码 1 的 [7] 段落把两者的差别实测了出来)。
  4. 忘记新鲜度:以为加密或”票据合法”就能防重放。 为什么错:攻击者无需读懂密文就能原样重发;Kerberos 的票据、JWT、TLS 的 0-RTT 早期数据都是可重放的。正确做法:为每条”有副作用”的请求提供 nonce、序列号、时间戳 + 重放缓存或幂等键——这与 RPC 一章讲的”重复请求过滤/幂等操作”是同一套工具。
  5. nonce / IV / 随机数重用或可预测。 为什么错:同一 $(K, \text{nonce})$ 在 GCM 下直接泄露两段明文的异或;CBC 的 IV 可预测会泄露明文相等关系;ECDSA 的 $k$ 重用会直接暴露私钥正确做法:用 CSPRNG(secrets/os.urandom)、计数器型 nonce 并持久化状态、给密钥设置使用上限与轮换周期,签名优先用确定性的 Ed25519。
  6. 自己实现密码学或自己设计协议。 为什么错:计时侧信道、填充预言、MAC-then-encrypt 的顺序错误、长度扩展、随机数质量——这些失败模式极其隐蔽且不体现在”测试通过”上。正确做法:用经过审计的库与标准协议;需要”组合”时也只组合标准构件(TLS 1.3、Noise、JOSE 的成熟配置),并避免用 ECB、MD5、SHA-1、裸 RSA。
  7. 依赖时钟却不保护时钟。 为什么错:Kerberos 的 $\pm 5$ 分钟窗口、证书有效期、租约/fencing、TOTP 全部以时间为信任基础;NTP 欺骗既能造成”全员认证失败”的 DoS,又能放大重放窗口。正确做法:使用认证的 NTP(NTS)、多源交叉校验与漂移监控、把时间源列入 TCB 与审计范围,并在设计上尽量避免把安全性质完全押在时钟上(保留重放缓存、优先 nonce)。
  8. 只在边界做安全(”内网可信”)或以为零信任是产品而不是架构。 为什么错:边界一旦被突破(VPN 口令、被攻破的代理、供应链后门),内部就是完全信任的平原;TLS 在负载均衡器终止后,内部流量是明文。正确做法:内部同样认证与加密(mTLS/服务网格)、按身份而非按网络位置授权、微分段、最小权限,并定期用”假设已被攻破”的方式做演练。

25.8 思考题(带答案)

题 1(计算与推演):某公司有 1000 名员工,需要任意两人之间都能保密通信。 (a) 若只用对称加密并预共享密钥,需要多少把密钥?若每把密钥用 32 字节存储,共需多少字节? (b) 改用公钥体系(每人对 RSA-2048 密钥对)需要多少对密钥?公钥部分共多少字节? (c) 改用 Kerberos 式的 KDC,需要多少把长期密钥?(d) 从”吊销一名员工”的角度比较三种方案的代价。

:(a) $\frac{1000 \times 999}{2} = 499{,}500$ 把,$\times 32$ B $\approx 15.98$ MB——存储不是主要问题,分发与吊销才是:新员工入职要与 999 人各建立一把密钥,离职要通知 999 人换钥匙。(b) 1000 对密钥($O(N)$),公钥部分 1000 × 256 B = 256 KB(私钥不公开,通常存于 HSM/密钥库);但必须解决”公钥真实性”——否则就是 25.2.9 的 MITM。(c) KDC 只需为 1000 个用户各存 1 把长期密钥(外加每个服务 1 把),会话密钥按需生成、用后即弃;总密钥数 $O(N)$,且无需两两预共享。(d) 吊销:对称方案要重建整个组(499,500 把里的 999 把);KDC 方案只需从 KDC 删除该主体(已签发的票据在生存期内仍有效——这是它的漏洞窗口,所以票据生存期要短);公钥方案依赖 CRL/OCSP(实践中”软失败”),现代做法用短有效期证书 + 自动轮换替代吊销。结论:可扩展性的真正瓶颈是”信任关系的管理”,而不是密钥的存储。

题 2(”直观但错误的想法”错在哪):”我们的微服务之间所有 RPC 都用 AES-256 加密了,所以内部通信是安全的,不需要再做别的。”

:这句话犯了四个独立错误。① 无完整性:只有加密没有 MAC/AEAD,攻击者可以翻转密文比特制造可预测的明文变化(CBC/CTR 尤其明显);② 无认证:任何能连上端口的人(包括租户网络里的其他容器)都能与你的服务”加密通信”——加密不回答”对方是谁”,内部服务必须用 mTLS/令牌做双向认证;③ 无新鲜度:密文可以原样重放,重复执行转账/扣库存等非幂等操作;④ 密钥分发未解决:微服务的密钥从哪来?硬编码在镜像里还是配置中心?谁能读?轮换与吊销怎么做?(现实中大量”内部加密”其实共享一把静态密钥,一处泄露即全盘皆输。)此外还有”授权”与”审计”两件事完全没被触及:认证通过不等于有权执行该操作,出了事也无人能复盘。正确做法:mTLS(工作负载身份 + 短证书)+ AEAD + 请求级授权与审计 + 幂等键/去重 + 密钥轮换(服务网格可以自动化其中大部分)。

题 3(攻击构造):设攻击者 Mallory 掌握 (i) 某次历史会话的密钥 $K_{AB}^{old}$,以及 (ii) 当时第 ③ 条消息(票据 $E(K_{B,AS},\{K_{AB}^{old}, A\})$)的完整密文。请写出它对原版 Needham-Schroeder 的攻击,并解释:(a) Bob 为什么无法察觉?(b) 修复后(时间戳票据 + 300 秒窗口 + 重放缓存),Mallory 需要什么新能力才能成功?(c) 若某部署把窗口放宽到 24 小时且不加重放缓存,攻击窗口有多大?

:攻击即 25.4 代码 2 的 sec2:Mallory 把旧票据当作新会话的第 ③ 条消息发给 Bob;Bob 用 $K_{B,AS}$ 解开(票据与其中的加密都真实有效),生成新挑战 $N_{B2}$ 并用 $K_{AB}^{old}$ 加密返回;Mallory 用泄露的旧密钥解密并回送 $N_{B2}-1$;Bob 认证通过并认为对端是 Alice。(a) Bob 没有可用于判断新鲜度的输入:$N_A$ 只在 Alice 与 AS 之间传递,票据里没有任何时间或随机数,因此”这是一张几天前签发的旧票据”对 Bob 完全不可见——协议的缺陷在于设计,不在于密码学强度。(b) 修复后,Mallory 必须让”票据的 T_issue 落在 $\pm 300$ 秒窗口内”避开重放缓存:它需要 (i) 一次窗口内的合法票据(例如实时截获一条正在进行的会话,即真正的 MITM,或诱导 Alice 在窗口内请求一次会话),(ii) 并且必须在缓存保留期内只重放一次(或因缓存失效——例如服务端重启丢失缓存——而获得额外机会);若它还企图篡改时间戳,就必须攻破 $K_{B,AS}$(对称加密假设被排除)。(c) 若窗口为 24 小时且无重放缓存,则任何在 24 小时内签发的票据都可被反复重放——攻击窗口从 0 膨胀到 86,400 秒,且可无限次重放;这说明“时间戳 + 窗口”与”重放缓存”是必须成对出现的组合:时间戳限制单次重放的时效,缓存消除窗口内的重复。

题 4(推演与迁移):某公司被要求做一次密码学升级。攻击者已经录下了过去 12 个月的全部 TLS 流量,并且刚刚获得了服务器的证书私钥(通过一次入侵)。 (a) 若服务器配置为 TLS 1.2 + RSA 密钥交换,攻击者能解密什么? (b) 若为 TLS 1.3(ECDHE),能解密什么?为什么? (c) 若为 TLS 1.3 + 会话票据(PSK),历史上”恢复的会话”是否安全?指出关键条件。 (d) 拿到私钥后,除了解密,攻击者还能做什么?

:(a) 可以解密过去 12 个月的全部会话:RSA 密钥交换中,会话密钥由客户端用服务器长期公钥加密的预主密钥决定,拿到私钥即可逐会话解出预主密钥并派生密钥——这就是”先收集、后解密”(harvest now, decrypt later)攻击成立的原因。CBC/RC4 等弱套件还会让事情更容易。(b) 不能解密过去任何一次完整握手(1-RTT)的会话:会话密钥来自临时 ECDHE 的 $Z = g^{ab}$,$a, b$ 已被丢弃,私钥只用于签名,无法从签名反推 $Z$——这正是前向保密。(c) 取决于实现:若会话票据(PSK)的加密密钥是独立且定期轮换的,则只有与被泄露密钥无关的票据历史才安全;若票据密钥就是(或可从)同一长期私钥派生、且从不轮换,那么拿到该密钥就能解开所有用它保护的恢复会话,前向保密被破坏。关键条件:票据密钥独立、定期轮换、且轮换后旧密钥被销毁。(d) 拿到私钥的攻击者还能冒充服务器(对任何未做 pinning 的客户端实施 MITM)、签发看似合法的短期伪造内容(在证书吊销生效前),以及解密未来的、非前向保密的会话结论:本题的教训是”密钥泄露的后果取决于协议设计“——TLS 1.3 把”事后解密”这条路堵死,但”事后冒充”仍需要吊销机制(CRL/OCSP/短证书)来收口。

题 5(综合设计):一个跨三个数据中心的分布式数据库要在”最多 1 个副本被完全攻破(可任意撒谎、可与外部攻击者通信)”的前提下保持一致性,同时把延迟控制在 100 ms 以内。你会怎么选:(a) 副本数与共识协议?(b) 需要哪些密码学机制?(c) 100 ms 的延迟目标会带来什么张力?

:(a) 拜占庭容错要求 $n \ge 3f+1$($f=1$ ⇒ 至少 4 个副本);若采用带不可伪造签名的协议,$n \ge f+2 = 3$ 副本即可,但必须接受”签名假设”(密钥必须放在 HSM/TPM 中且不可导出,随机数必须来自 CSPRNG)。协议上选 PBFT 系的(三阶段、$O(N^2)$ 消息)或签名优化的 BFT 变体,并在 view change(换主)时格外小心——这是 BFT 最容易被实现错误击穿的地方。(b) 需要:密钥管理(每副本一对签名密钥 + 证书/PKI,用 HSM 保护)、消息认证(每条协议消息签名或 MAC,且必须包含视图号/序号以防跨视图重放)、客户端请求的去重(幂等键 + 重放缓存,防止攻击者重放已提交的写请求)、审计日志(哈希链 + 签名,保证事后可取证)、时钟监控(超时与租约依赖它)。(c) 张力是根本性的:跨洲 RTT 约 150 ms,而 BFT 至少需要 3 个阶段(pre-prepare/prepare/commit)⇒ 延迟已经超出 100 ms;再叠加签名验证(每条消息 $10^4$ 次/秒量级的验签能力)与 $O(N^2)$ 消息量,“全球强一致 + 拜占庭容错 + 100 ms”三者不可能同时满足(这正是 CAP/延迟与容错模型三重约束的现实版本)。可行折中:把 BFT 只用于低频的关键路径(配置、成员变更、审计日志、跨域协调),高频数据用崩溃容错 + 认证($2f+1$ 副本、$O(N)$ 消息、1–2 RTT),并用租约/leader 本地区域化把常见请求压到 1 个 RTT;同时明确写下”我们容忍哪一类故障、在哪一层容忍、以及攻击者要付出多大代价才能越界”。