Lecture 25: Security — 分布式系统安全(Security in Distributed Systems)
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 到密码学协议的”形式化验证”传统)。它的内容可以精确表述为两条:
- 攻击者完全控制网络:网络就是攻击者。它可以窃听任何消息、篡改任何消息、删除/阻断任何消息、注入任意它自己构造的消息、重放任何它见过的消息、改变消息的路由与顺序,并且可以在同一时间与多个参与方并发地跑任意多个会话(concurrent sessions)。用形式化的说法:所有 $p_i \to p_j$ 的消息都经过攻击者;攻击者收到消息后可以任意选择”转发、修改、丢弃、复制”。
- 攻击者不能破解密码学原语:即“完美加密”假设(perfect encryption assumption)——不知道密钥就无法解密,没有私钥就无法生成合法签名,哈希不可求逆、不可碰撞,随机数不可预测。攻击者知道的只是”合法主体本该知道的公钥/公开参数”以及它自己生成的密钥。
- 推论(攻击者闭包):攻击者能从它已知的消息与密钥出发,用有限步做这些事——拼接(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 还能被用作掩护(把监控/仲裁者打瘫,同时篡改数据)或触发故障转移风暴(心跳超时导致连锁重选主)。
- 防御(分层,且必须承认”完全防住不可能”,目标是提高成本、保底可用):
- 网络层:Anycast 分散流量、上游清洗中心(scrubbing)、黑洞路由、SYN cookie(无状态完成三次握手)、连接限制与超时;
- 应用层:速率限制(rate limiting)、配额(per-tenant quota)、连接挑战(Proof-of-Work / JS challenge / CAPTCHA)、请求成本预算、限流降级;
- 架构层:CDN 缓存吸收流量、无状态化与自动扩容、优先级队列保证关键路径(如”读”优先于”写”)、多区域冗余;
- 治理层:识别业务逻辑型 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 | 并行性 | 认证 | 安全性要点 |
|---|---|---|---|---|---|
| ECB | Electronic Codebook | 无 | 可并行 | ✗ | 不安全,禁止使用:相同明文分组 → 相同密文分组,直接泄露数据模式(经典例子:ECB 加密的位图仍能看出轮廓) |
| CBC | Cipher Block Chaining | 需要随机且不可预测的 IV | 加密串行、解密可并行 | ✗(需另加 MAC) | 链式:$C_i = E(K, M_i \oplus C_{i-1})$。IV 可预测会泄露”两段明文是否相同”,且易受填充预言攻击(POODLE、Lucky13) |
| CTR | Counter | 需要每次加密唯一的 nonce | 完全可并行 | ✗(需另加 MAC) | 把分组密码当流密码用:$C_i = M_i \oplus E(K, \text{nonce}\vert i)$。nonce 重用是灾难性的(两次密文异或 = 两次明文异或) |
| GCM | Galois/Counter Mode | 96 位 nonce(推荐值) | 可并行 | ✓(128 位认证标签) | AEAD:一次调用同时给出机密性与完整性,是现代默认选择;同一密钥下 nonce 绝不可重复 |
- 优点:极快。在支持 AES-NI 的服务器 CPU 上,AES-GCM 单核吞吐可达每秒数 GB(见 25.5),比公钥运算快三到六个数量级;分组密码对任意长度数据都很自然(分组链/流模式)。
- 缺点与”密钥分发问题(key distribution problem)”:对称加密无法在互不认识、从未共享过密钥的两方之间安全地建立密钥。更严重的是它的可扩展性:若 $N$ 个用户要两两安全通信,每人必须与其余每个人各持一把不同的密钥,总密钥数
$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)保密,用于解密与签名。它解决两个对称加密解决不了的问题:密钥分发与不可否认。
- 直观解释:公钥加密像街边的邮筒——谁都能把信投进去(用公钥加密),但只有邮筒的主人有一把钥匙能把信取出来(用私钥解密)。数字签名像只有你能盖的钢印(私钥盖章),任何人拿你的公开印模(公钥)都能验出这枚章是真的,但没人能仿制。
- 讲义给出的基本等式(这两条必须能默写):
第一条是加密(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$)。
- Alice 选一个私密随机数 $a$($1 \le a \le p-2$),计算 $A = g^{a} \bmod p$,把 $A$ 发给 Bob;
- Bob 选一个私密随机数 $b$,计算 $B = g^{b} \bmod p$,把 $B$ 发给 Alice;
- 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。
- 用途:
- 消息摘要 / 完整性:$H(M)$ 与可信渠道得到的摘要比对(如软件下载页给出的校验和)——注意它只能检测意外损坏,不能抵抗主动攻击(攻击者可以同时替换文件和摘要),抵抗主动攻击需要 MAC 或签名。
- 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 或将密钥放在末尾的构造。
- 口令存储:必须加盐(salt)+ 慢哈希。盐是每个用户独立的随机值(防彩虹表与”两个用户同口令则哈希相同”),慢哈希(PBKDF2、bcrypt、scrypt、Argon2)通过大量迭代或内存硬化把单次尝试的成本抬高几个数量级。绝不能直接存明文、也不能存单轮 SHA-256 的口令哈希。
- 数字签名的前置压缩:先 $h = H(M)$ 再对 $h$ 签名(原因见 25.2.8)。
- “哈希 ≠ 加密”必须讲清:
- 加密是双射且可逆的(有密钥可还原明文);哈希是多对一的压缩且不可逆(无法还原,也不需要密钥);
- 哈希无密钥(除 HMAC 这种”带密钥的哈希”),因此哈希不能提供机密性——把口令哈希后放进数据库,攻击者拿到数据库仍能用字典离线爆破(见 25.4 代码实测);
- 反过来,加密不提供完整性摘要的功能(除非用 AEAD),因为密文可以被篡改而”看起来正常”。
25.2.8 数字签名(Digital Signature)与 MAC 的根本区别
- 定义与目的:数字签名用私钥对消息(的哈希)生成一段只有签名者能产生的证据,任何人用其公钥都能验证。讲义给的四个属性是判据:真实的(authentic)、不可伪造的(unforgeable)、可验证的(verifiable)、不可否认的(non-repudiable)。
- 直观解释:数字签名像只有你能盖的钢印:你盖上去(私钥签名),任何人拿你的公开印模一比就知道是你(公钥验签),别人没有那枚章所以仿不出来(不可伪造),而你也无法否认——因为全城只有你有那枚章(不可否认)。
- 标准流程(先哈希再签名,必须讲清原因):
- 计算摘要 $h = H(M)$;
- 用私钥生成签名 $\sigma = \text{Sign}(K^{priv}, h)$(RSA-PSS 里是”用私钥做模幂”,ECDSA/EdDSA 里是曲线上的运算);
- 接收方计算 $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 分配的唯一编号 | 吊销时定位具体证书 |
| 签名算法 | 如 sha256WithRSAEncryption、ecdsa-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)与”回溯必须终止于受信任的根“:
- Alice 的银行账户证书:
{Type: Account, Name: Alice, Account: 12345, CA: Charlie's Bank, Signature: K_Cpriv(Hash(Name+Account))}; - 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))}; - Banker’s Federation 的公钥证书:
{Type: Public Key, Name: Banker's Federation, Public Key: K_Fpub, CA: Verisign, Signature: K_Verisign_priv(Hash(Name+Public Key))}。 验证时必须逐级向上验签,直到某个”预置在信任库中的根”为止——否则链条可以无限延伸(或延伸到攻击者自签的根)。
- Alice 的银行账户证书:
叶子证书 中间 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@REALM或service/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_S,Auth1/Auth2 即 Authenticator,T 即 T_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 的局限与注意事项(必须讲全):
- KDC 是单点:KDC 故障 ⇒ 全 realm 无法获得新票据(已获得的票据在有效期内仍可用,这缓解了一部分影响);工程上部署 KDC 副本,但副本之间的密钥数据库同步与主密钥(master key)保护是新挑战,且副本扩大了攻击面(KDC 是最高价值的攻击目标,因为它持有所有长期密钥的加密副本)。
- 时钟同步依赖:时钟被操纵(NTP 欺骗或客户端改时间)会直接影响认证——窗口过大则重放缓存放宽,窗口过小则合法请求被拒(25.4 代码 2/3 会实测这两种失败)。
- 口令猜测攻击:AS 的应答
E(K_C, ...)用口令派生的密钥加密,而这段密文在网络上可被截获(在 Kerberos 4 与”未开启预认证(pre-auth)”的配置里尤其明显),因此可以做离线字典/暴力破解(真实世界的 “AS-REP roasting”)。这要求强口令 + 开启预认证 + 现代 KDF 迭代次数(目录服务还会有锁定策略与登录失败审计)。 - 票据生命周期与吊销困难:票据一旦签发,在生存期内无法被撤销(KDC 无法轻易召回);因此生存期要短(小时级),并在服务端施加额外授权检查(细粒度权限通常由服务自己决定,而不是票据)。
- 委派(delegation)与转发(forwarding)风险:为了让服务代用户访问后端资源(如 HDFS 中 NameNode 代表用户访问 DataNode),需要可转发票据,这扩大了票据泄露后的影响范围(现代实践用约束性委派 S4U2Proxy 与范围限制来收敛)。
- 在现代系统中的位置:Windows Active Directory(Kerberos 是域认证的默认协议,配合 SPNEGO/Negotiate 用于 SMB、LDAP、RDP 的 SSO);Hadoop 的 Kerberos 认证(
kinit获取 TGT,keytab提供服务的长期密钥,NameNode/DataNode/RPC 之间用票据互认);NFSv4sec=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 的常见配置错误(必须讲):
- 禁用证书验证:代码里写
verify=False、ssl._create_unverified_context()、curl -k、把自签证书加入信任库而不做固定——这等于把 TLS 降级成”能加密但谁都能冒充”的通道,MITM 完全可行。正确做法:始终验证,用系统的信任库或明确的 CA bundle,测试环境用私有 CA 而不是关掉验证。 - 使用过时的协议版本或弱密码套件: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。
- 降级攻击(downgrade attack):MITM 篡改 ClientHello 让双方协商到更弱的版本/套件。防御:TLS 1.3 在
ServerHello.random的最后 8 字节里放了DOWNGRD哨兵,配合 Finished 覆盖完整转录,任何对握手的篡改都会让 Finished 校验失败;此外还有 ALPN/版本协商的一致性与 HSTS 防”先降级到 HTTP 再重定向”。 - 重协商 / 会话恢复相关攻击:TLS 1.2 的重协商可被用来注入前缀(2009 年重协商攻击),会话票据若不轮换密钥则历史流量可在票据密钥泄露后被解开。TLS 1.3 直接用 Post-Handshake Authentication 与 PSK 前向保密改进取代了旧机制。
- 证书固定(certificate pinning)的取舍:固定(在客户端硬编码期望的 CA/公钥/SPKI 哈希)能抵抗”恶意 CA”与内网 MITM 代理,但运维代价高:证书轮换、CA 更换都会让旧客户端彻底失联(HPKP 因为”自锁死”风险已被弃用)。正确做法:用固定的”备份集合”或固定中间 CA,配合 CT 监控,并在移动端以”可远程更新的配置”下发固定信息。
- 只在边界做 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),其
sub、iss、aud、exp、nonce等声明把”这次登录是谁、给哪个客户端、何时过期”说清楚。所以”用 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 编码,任何人可读。常见漏洞:alg: none攻击:把 header 的算法改成none并去掉签名;若库不强制白名单算法即被接受。- 算法混淆(algorithm confusion):把
RS256改成HS256,让验证方用公开的 RSA 公钥当作 HMAC 密钥来验签——攻击者用同一公钥计算 HMAC 即可伪造 token。 - 不校验签名 / 只在网关校验一次:内部服务信任任何传入的 JWT;或把公钥从可控地址(
jku/x5u头指定的 URL)动态拉取。 - 不检查
exp/nbf/aud/iss,或允许超长有效期、不做撤销(JWT 无状态的反面就是难以撤销)。 正确做法:服务端固定允许的算法与密钥来源、严格校验全部声明、有效期尽量短并配合刷新令牌与撤销列表、敏感数据不要放进 payload。
- SSO(单点登录)与 SAML:SSO 让用户一次登录即可访问多个服务,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)——把关键操作拆给不同主体,使单点背叛无法完成整个流程(例:发布权限与生产审批权限分离)。
- 分布式授权(现代系统怎么做):
- OAuth 的 scope:把授权范围编码进令牌,资源服务器只看令牌与 scope 即可判定,不需要回到中心数据库(可扩展性好,但撤销困难 ⇒ 短有效期 + 刷新令牌 + introspection)。
- 基于能力(Capability-based)的安全:Macaroons(Google,2014)把权限做成可离线衰减(attenuation)的链式 HMAC 凭证——”持有者可以把权限再缩小后转交”,非常适合”把受限凭证交给第三方服务”的场景;UCAN 是基于公钥的能力令牌,用于去中心化系统。核心思想与 25.2.15 的能力表一致:权限是一个可以被传递的、自证的凭证,而不是一张需要查询的中心名单。
- 服务网格中的 mTLS + 授权策略:Istio/Linkerd 为每个工作负载签发短期身份证书(SPIFFE ID,如
spiffe://cluster/ns/prod/sa/payments),服务间默认 mTLS,并在 sidecar 里按”源身份 + 目标服务 + 方法/路径”执行授权策略。它把”网络位置”换成”密码学身份“作为授权依据——这是零信任落地的关键技术。 - 零信任架构(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 内。 - 时间与安全的交叉(连接时钟同步一章):时间同步是安全基础设施,而不是”运维细节”:
- Kerberos 的认证符依赖 $\pm 5$ 分钟的时钟窗口 ⇒ 攻击者通过 NTP 欺骗(伪造 NTP 应答,或直接改客户端时间)可以让重放缓存形同虚设或让合法请求被大面积拒绝(DoS);
- 证书有效期校验依赖本地时钟 ⇒ 时钟被拨到证书有效期内,即可接受已过期(可能已泄露私钥)的证书;
- 租约(lease)与 fencing依赖时钟上界 ⇒ 时钟不准会导致两个主节点同时自认为持有租约(脑裂),这在存储系统里可能造成数据损坏;
- TOTP 验证码、审计日志的时间戳、事件因果排序都建立在可信时间之上。结论:NTP 本身必须被保护(认证 NTP/NTS、多源交叉校验、监控时钟漂移),并且安全机制不应把全部正确性押在时钟上(这正是 Kerberos 同时保留重放缓存、而 Kerberos 的现代替代方案更倾向挑战-应答的原因)。
25.2.17 安全工程原则(Security Engineering Principles)
这十条是本讲的”操作手册”,也是判断一个系统设计是否成熟的最快方法。前五条几乎逐字对应 Saltzer & Schroeder(1975)的经典原则,后五条是分布式系统语境下的补充。
- 纵深防御(Defense in Depth):不依赖任何单一防线——TLS + 认证 + 授权 + 审计 + 网络分段 + 运行时检测层层叠加。理由:任何单层都会失效(CA 会被攻破、库会有漏洞、配置会写错),多层使”单点失败”不至于变成”全盘失败”。
- 最小权限(Least Privilege):每个组件、服务账号、容器只拥有完成任务所需的最小权限,且默认拒绝(fail-safe defaults)。理由:权限是攻击者的杠杆;被攻破的一个小服务若只有只读权限,损失就从”全库泄露”降为”读到一张表”。
- 失败安全(Fail Safe / Fail Secure):失败时默认拒绝而不是默认放行。反例:证书吊销检查(OCSP)在网络不可达时默认放行(soft-fail)——这等于让攻击者通过”掐断 OCSP 流量”来绕过吊销;正例:认证服务的缓存失效策略、Kubernetes admission webhook 的
failurePolicy: Fail(但这同时引入可用性风险,需要在”安全”与”可用”之间显式取舍并写出理由)。 - 不要自己实现密码学(Don’t Roll Your Own Crypto):用经过审计的库(OpenSSL/BoringSSL/libsodium/Go crypto)与标准协议(TLS 1.3、Noise、Signal、JOSE 的成熟配置)。理由:密码学的失败模式极其隐蔽——计时侧信道、填充预言、nonce 重用、随机数质量、MAC-then-encrypt 的顺序,任何一处都会让”看起来对”的实现变成漏洞工厂。推论:也不要用自己设计的协议(”把消息加密两次就安全了”通常意味着两次都不安全)。
- Kerckhoffs 原则(1883):安全性应只依赖于密钥的保密,而非算法或实现的保密。理由:算法会被逆向、会被泄露、会被内部人员知道;而公开算法让全世界帮忙找漏洞(AES、TLS 都是公开的)。注意:”不公开算法”最多只能算额外的(脆弱的)屏障,不能作为安全论据;但也不等于必须公开实现细节(如密钥的具体存放位置与运维流程)。
- 端到端原则(End-to-End Argument, Saltzer/Reed/Clark 1984):安全属性最好在端点实现,而不是依赖中间层。理由:中间层(网关、代理、服务网格、TLS 终止点)既不可靠也不完整——如果”认证”只发生在边界代理上,代理之后的每一个内部跳都是完全信任的,横向移动即可绕过;而端点可以校验端到端的签名或 MAC(例如消息在生产者签名、消费者验签,中间任何节点都无法伪造)。
- 假设会被攻破(Assume Breach):设计时假设部分组件已被攻破(这正是拜占庭容错的思路),因此要限制爆炸半径(微分段、按租户隔离)、保证”被攻破也能检测”(审计与完整性监控)、并保证关键决策需要多个独立主体的参与(quorum、双人审批、多方签名)。
- 审计与监控(Auditing & Monitoring):持续记录”谁在何时做了什么”,日志必须不可抵赖、不可静默修改(追加写、WORM、哈希链或签名、集中化收集与告警),并做异常检测。理由:检测是”假设会被攻破”的前提——如果攻破无法被察觉,纵深防御与最小权限的价值都会被时间抹平(SolarWinds 事件的关键教训之一就是”官方签名的更新也可能是攻击”)。
- 先建威胁模型,再选机制(Threat Modeling First):本讲 25.2.3 的四步法是这条原则的具体化。没有威胁模型的安全设计等于没有目标函数的优化:你会花大力气加密一件本来就公开的数据,却忘了攻击者真正的入口是一个未鉴权的内部管理接口。
- 可用的安全才是安全(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 碰撞概率累积(生日界)
算法逻辑解说(含数值小例子)
- 发送方为每一条消息生成一个新 nonce(例:
nonce = 0x000000000000000000000001,下一条为...002)。计数器型 nonce 的好处是绝对不会重复,但要求状态持久化(进程重启后不能重置)。 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$。- 接收方先算标签、再做常量时间比较;若 $T$ 不匹配就整体丢弃(不能”部分解析”、不能返回错误细节——否则会形成填充/解密预言(padding oracle),历史上的 POODLE、Lucky13 类攻击都是靠”错误信息有差异”逐字节恢复明文的)。
- 小例子:$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 已将其删除。)
算法逻辑解说
- Alice 生成临时密钥对 $(a, A)$,发送 $A$ 与 $R_C$;Bob 生成 $(b, B)$,返回 $B$、证书链与一个覆盖整段握手转录(transcript)的签名。
- Alice 先验证证书链(确认
pk_B真的属于 Bob),再用pk_B验证签名(确认对方此刻持有 $sk_B$,且 $R_C, R_S, A, B$ 一个比特都没被改)。 - 双方各自计算 $Z = g^{ab}$:Alice 用 $B^{a}$,Bob 用 $A^{b}$。$Z$ 从未出现在网络上,攻击者即使记录了 $A$、$B$,也只能面对 CDH 问题。
- KDF 不是可选项:DH 的共享秘密是群元素而非均匀比特串(有结构、有偏差),必须用 HKDF(HMAC 为底的提取-扩展)把它”提纯”成密钥;同时把 $R_C, R_S$ 与转录哈希作为输入,使双方派生出相同的密钥且密钥与本次握手绑定(任何消息被改动都会导致密钥不一致 ⇒ Finished 校验失败)。
- 小例子(教学素数 $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)
算法逻辑解说(含数值小例子)
- 取 $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)。
- 消息 $M = \text{“transfer 100 to Bob”}$,$h = H(M)$ 得到一个整数(真实系统是 256 位摘要)。
- 签名 $\sigma = h^{d} \bmod n$;验证者计算 $\sigma^{e} \bmod n$ 并检验它是否等于 $h$(真实系统还要检验 PSS 编码中的填充结构与盐)。
- 篡改 $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 位:
- $A \to AS$:
(A, B, 1001); - $AS \to A$:$E(K_{A,AS}, \{1001, B, K_{AB}, E(K_{B,AS},\{K_{AB}, A\})\})$。Alice 解开后核对
1001一致 ⇒ 确认这条应答是针对本次请求的(新鲜),得到 $K_{AB}$ 与一张她读不懂的票据; - $A \to B$:转交票据;$B$ 用 $K_{B,AS}$ 解出 $\{K_{AB}, A\}$ ⇒ 知道”与谁、用什么密钥”;
- $B \to A$:$E(K_{AB}, \{2002\})$;
- $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}}$(这是一个现实的假设:会话密钥的生命周期与存储安全性通常远低于长期密钥)。构造如伪代码所示:重放旧票据 + 用旧密钥应答新挑战。结论:
- Bob 的
AUTHENTICATED(A)为真,然而 Alice 未参与 ⇒ 认证性质不成立; - 攻击者掌握了会话密钥 $K_{AB}^{\text{old}}$ 并让 Bob 接受它为本次会话密钥 ⇒ 后续”Bob 与 Alice 的会话”完全在攻击者控制之下(可读可改);
- 因此 Needham-Schroeder 的安全性不是无条件的:它只在”历史会话密钥永不泄露”这一强假设下成立,而这个假设在实践中不成立。这就是为什么现代协议要求”票据必须携带新鲜度”(对比 25.3.5 Kerberos 的做法)。
- Bob 的
- 两条修复路线及其新引入的假设:
- 时间戳票据(Kerberos 的选择):票据里加
T_start,$B$ 只接受 $\vert T_{now} - T_{start}\vert \le \Delta$ 的票据,并用重放缓存拒绝重复。代价:新的假设 (A4) 各主体时钟松同步($\vert \text{skew}\vert \le \Delta$),且 $\Delta$ 越大重放窗口越大、越小则合法请求越容易被误拒(25.4 代码 2 会实测”钟快 600 秒 ⇒ 合法请求被拒”)。 - 额外一轮挑战-应答(如 Otway-Rees、NS-Lowe 的改进思路):让 $B$ 也向 AS 提供一个 nonce,使票据内容包含”Bob 也认可的新鲜度”,从而不需要时钟。代价:多一个 RTT($AS$ 参与更多轮次)、$AS$ 的负载上升。
- 时间戳票据(Kerberos 的选择):票据里加
- 活性(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)
算法逻辑解说(含具体数值例子)
- 用户
alice@EXAMPLE.COM在工作站执行kinit(输入口令 ⇒ 派生 $K_C$)。$N_1 = 0x9A3F\ldots$(128 位随机数)随 AS-REQ 发出。 - AS 返回
AS-REP:Alice 用它派生的 $K_C$ 解密成功 ⇒ 既证明了 AS 是真的(只有 AS 知道 $K_C$ 的内容),也证明了她的口令正确;核对 $N_1$ 一致 ⇒ 应答新鲜。她得到 $K_{C,TGS}$ 与 TGT(T_start = 10:00,lifetime = 10h)。 - 访问
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)记入重放缓存。 - TGS 返回服务票据
TICKET_S(用K_S加密,Alice 打不开)与新会话密钥 $K_{C,S}$(用 $K_{C,TGS}$ 加密,只有 Alice 能打开)。 - Alice 用
Auth2 = E(K_{C,S}, {alice, 10:05:04})访问 NameNode;NameNode 用自己的 $K_S$ 解开票据 ⇒ 得到 $K_{C,S}$ ⇒ 解开 Auth2 ⇒ 校验时间与缓存 ⇒ 授权。 - 若启用双向认证,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 却看到降级哨兵或降级后的版本",必须中止握手
算法逻辑解说
- 客户端发送 ClientHello(含 $R_C$ 与临时 ECDHE 公钥 $A$);服务器回 ServerHello(含 $R_S$ 与 $B$),其后所有消息都被加密(TLS 1.3 相比 1.2 的重要改进:证书与签名不再明文暴露,减少元数据泄露与被动指纹)。
- 服务器发送 Certificate(链)+ CertificateVerify(用 $sk_S$ 对转录哈希签名)。这个签名同时做了两件事:证明私钥持有与保护整段握手(任何字段被篡改 ⇒ 签名验证失败)。
- 客户端验证证书链(清单见 25.2.9 与本节 Safety-1),计算 $Z = B^a$,用 HKDF 派生握手密钥与应用密钥。
- 双方交换 Finished(对转录哈希的认证加密):它把”整段握手 + 双方随机数”绑在一起,任何降级/剪裁/篡改都会让 Finished 校验失败。
- 之后应用数据用 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——
hashlib、hmac、secrets、os、struct提供的是哈希、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),无异常退出。
【代码做什么?】
demo1哈希与雪崩效应:对只差 1 个比特的两个输入(cs425与bs425)分别算 SHA-256,输出两个十六进制摘要,并统计”有多少个 hex 字符不同、多少个比特不同”(实测 64 个 hex 字符中有 60 个不同、256 比特中有 127 比特翻转 ≈ 一半);同时打印 MD5(32 个 hex 字符)与 SHA-256(64 个 hex 字符)的长度差,以及”10 万字节的大消息仍然只输出 64 个字符”来体现定长输出。demo2单向性与小空间穷举:对 4 位数字 PIN(空间 $10^4$)的 SHA-256 摘要做顺序暴力搜索,打印尝试次数(实测 7474 次后命中口令7473);再外推 8 位小写字母($26^8 \approx 2.09\times10^{11}$)在每秒 $10^9$ 次哈希下的耗时,说明”不可逆”只是计算意义上的困难,口令熵太低时穷举依然可行。demo3HMAC 与篡改检测:用hmac.new(key, msg, hashlib.sha256)生成标签;先验证正确消息(True),再把转账金额从 100 改成 999(False),再换错密钥(False);并说明为什么必须用hmac.compare_digest而不是==(常量时间比较,否则攻击者可通过响应时间逐字节猜出标签——时序侧信道)。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 年);同时验证”同口令 + 不同盐 ⇒ 存储值不同”。demo5Diffie-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 收到的明文完全正确、双方都没有察觉。demo6RSA 教学版:$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 填充”。demo7MAC vs 数字签名(不可否认性):对同一段消息分别用共享密钥 MAC 与 RSA 签名处理。先演示 Alice 与 Bob 算出的 MAC 完全相同(⇒ 双方都能造),Bob 伪造一条 Alice 从未发送的消息并生成合法 MAC;”法官”用共享密钥验证通过,于是陷入”Alice 说是 Bob 造的、Bob 说是 Alice 发的”无法裁决的场面。再换成 RSA 签名:Bob 没有 $d_A$,伪造消息验签失败;真消息用 Alice 公钥验签通过 ⇒ 可归因、可裁决。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
【代码做什么?】
- 教学原语与主体建模:
E(k, obj)把对象 JSON 序列化后加上长度前缀,用sha256(k \| iv \| counter)生成的密钥流 XOR,再附 16 字节 HMAC 标签(因此篡改会被检测,D()返回None并打印告警);长期密钥由sha256("K_A_AS")这类可读标签派生,票据与 nonce 在转录里保持人类可读(kab#1、N_A=7865),方便逐条读懂消息流。Alice、AS、Bob、Mallory四个类各自维护显式状态(peer、kab、authenticated、seen重放缓存、skew时钟偏移),Clock提供可确定性推进的模拟时钟。 sec0长期密钥表:断言K_A_AS只被 Alice/AS 持有、K_B_AS只被 Bob/AS 持有且两者不同——这是后面一切安全论证的假设基线。sec1正常五条消息流程:完整打印每条消息的发送方、接收方、明文/密文、解密结果,并断言alice.kab == bob.kab、alice.peer == "Bob"、bob.peer == "Alice"、双方authenticated == True。sec2Denning-Sacco 攻击(本代码的核心):先跑一次正常会话并”录制”下第 ③ 条消息(旧票据)与泄露的会话密钥kab#2;随后让 Mallory 只带着这两样东西重放旧票据给 Bob。Bob 用 $K_{B,AS}$ 解开票据(合法)→ 发新挑战 $N_{B2}$ → Mallory 用泄露的旧密钥解开并回答 $N_{B2}-1$。断言bob2.peer == "Alice"且bob2.authenticated == True且alice2.kab is None——即”Bob 相信对方是 Alice,而真 Alice 从未参与”。sec3修复与代价的实测:把票据改为携带T_issue与lifetime,Bob 增加两个检查(时间窗口 $\pm 300$ 秒、重放缓存(client, T_issue))。依次实测四种情形:(3.1) 重放 3600 秒前的旧票据 ⇒ 被拒(时间戳超窗);(3.2) 当前时刻的新鲜票据 ⇒ 通过;(3.3) 把发起方时钟拨快 600 秒(票据本身完全合法)⇒ 合法请求被拒,这就是”防重放外包给时钟”的代价;(3.4) 窗口内重放同一张新鲜票据 ⇒ 被重放缓存拦下(说明时间戳不能单独工作,必须配重放缓存)。- 最终总结:打印三条教训(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)。【仅用于教学】
【代码做什么?】
- KDC 与主体建模:
AS、TGS、Server、Client四类对象;票据格式与真实 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提供可推进的模拟时间。 [0]密钥表:打印三个主体的长期密钥,并特别指出 Alice 的K_C不是随机数,而是口令cs425经 PBKDF2(本演示 1000 次迭代)派生的——这正是[4]离线口令猜测的攻击面。[1]正常六步流程:逐条打印 AS-REQ/AS-REP、TGS-REQ/TGS-REP、AP-REQ/AP-REP,最后[6]的服务端回包中{T_now + 1}解密成功 ⇒ 断言双向认证完成、双方持有同一 $K_{C,S}$。[2]攻击 1:票据重放:把时钟推进 120 秒(此时票据仍在有效期内,用以说明”票据本身不防重放”)。(a) 原样重放票据 + 录下的 Authenticator ⇒ 被重放缓存拒绝((alice, 1700000000)已见);(b) Mallory 尝试伪造一个新时间戳的 Authenticator ⇒ 标签校验失败,因为她不知道 $K_{C,S}$。并打印一条真实的工程细节:重放缓存必须以(client, timestamp)为键并至少保留 $2\Delta = 600$ 秒,否则同一秒内的正常认证会被误判为重放。[3]攻击 2:时钟偏移:把客户端时钟拨快 600 秒($>300$ 秒窗口)⇒ (a) TGS 拒绝、(b) 同一张完全合法的服务票据交给 Server 也被拒、(c) 反向(服务器快 600 秒)也失败。打印”KDC 照旧签发了 TGT 且 TGT 有效——失败的唯一原因就是时钟不同步”。[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 迭代次数。[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 RTT | 3 RTT | 有 | 无 | 无(但不抗”事后解密”) |
| TLS 1.2(ECDHE + 会话恢复) | 1–2 RTT | 2–3 RTT | 恢复时可省 | 有 | 无 |
| TLS 1.3(完整握手) | 1 RTT | 2 RTT | 有 | 强制 | 无 |
| TLS 1.3(PSK 恢复) | 0 RTT(应用数据随第一个飞行发出) | 1 RTT(TCP) | 可省(依赖票据) | 有条件(psk 前向保密取决于实现) | 有!早期数据可被重放 |
| QUIC(TLS 1.3 内嵌) | 1 RTT / 0 RTT | 1 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/s | HSM 成为关键路径单点;密钥不可导出会限制应急响应 |
| 会话复用 / 票据 / 长连接池 | 消除重复握手(1–2 RTT + 证书验证) | 票据密钥成了长期秘密,需轮换;会话固定(session fixation)风险 |
| 0-RTT / QUIC 早期数据 | 省去 1 RTT(跨洲约 150 ms) | 可重放:只能用于幂等操作 |
| 批量化与流水线(把多次签名合并、批量验签) | 提高吞吐、摊薄固定开销 | 批处理引入延迟与”部分失败”处理复杂度;批内错误可能放大 |
| TLS 在边界终止 + 内部明文 | 省 CPU、便于观测与缓存 | 内网重新变成”可信”(横向移动风险);违背端到端原则 |
| 服务网格自动 mTLS | 无侵入地让内部流量加密与认证 | sidecar 增加延迟(每个 RTT 数次代理)与资源开销;证书轮换依赖控制面可用性 |
| 降低认证强度(关掉校验、拉长有效期、放宽时钟窗口) | 立竿见影的可用性提升 | 这是一步步走向”没有安全”的标准路径——每一次让步都应记录为显式的风险接受 |
结论:安全机制的成本必须在常态(无攻击)下度量(讲义第四步),因为那是 99.99% 的时间;但在攻击发生时,没有任何性能指标值得拿来交换安全性。因此正确的工程姿势是:把安全开销摊到架构上(会话复用、硬件加速、分层),而不是把安全强度降下来。
25.6 关键要点
- 分布式安全的根本困难是”没有物理边界、没有可信的中央权威”:因此不能靠”位置/内网”判断可信,只能靠密码学证据(密钥、签名、票据)来重建信任;任何”内网可信”的假设都是把攻击者的工作量降到零。
- “加密”只解决机密性:完整性要 MAC/AEAD、身份要认证协议与证书、新鲜度要 nonce/时间戳/序列号、授权要访问控制、可追责要审计与签名——这五件事没有一件能靠”加个密”顺带解决。
- 真正的难点是密钥分发与信任的集中:对称密钥是 $O(N^2)$($N=1000$ 需 49.95 万把),公钥把它降到 $O(N)$ 对,但代价是把信任集中到少数 CA/KDC——DigiNotar 与 KDC 单点都是这份账单的体现。
- 混合加密不是优化而是唯一可行架构:公钥($10^3\sim10^5$ 次/秒)只用来传 32 字节的会话密钥,对称 AEAD(GB/s)搬全部数据,两者相差 $10^4$ 倍以上。
- 认证协议的安全性几乎总是落在”新鲜度”这一行代码上:nonce 只能让生成者确认新鲜;票据/令牌自身没有新鲜度就会被重放(Denning-Sacco);用时间戳换新鲜度的代价是依赖时钟同步($\pm 5$ 分钟窗口),因此时间源(NTP)必须被当作 TCB 的一部分来保护。
- 安全是”假设的组合”,不是某个机制:签名把拜占庭容错从 $3f+1$ 降到 $f+2$(用密码学假设换副本数),但一旦签名/随机数/哈希被攻破,所有界同时失效。所以每一个安全声明都必须写清:信任谁、依赖什么假设、假设失效时会怎样。
本章黄金法则:分布式安全的核心困难是”没有可信的边界与中央权威“——因此需要密码学提供不可伪造的证据(签名、MAC),需要 PKI 提供公钥的真实性(但把信任集中到 CA),需要认证协议提供身份的确认(但都依赖 nonce / 时间戳 / 时钟同步)。安全不是某个机制,而是贯穿每一层的假设组合。
25.7 常见陷阱与注意事项
- 以为”加密了”就等于”安全了”。 为什么错:加密只提供机密性,密文仍可被篡改、重放、延迟、丢弃;CBC/CTR 的密文可以被主动修改出可预测的明文变化。正确做法:用 AEAD(AES-GCM、ChaCha20-Poly1305)或”先加密后 MAC”,并让认证覆盖所有语义字段(含协议版本、目标身份、AAD)。
- 禁用证书验证(
verify=False、curl -k、自签证书塞进信任库)。 为什么错:这让 TLS 退化为”能加密但人人可冒充”,MITM 一步到位(见 25.2.9 的图)。正确做法:始终验证链、域名(SAN)、有效期与用途;测试环境用私有 CA 并正确分发根证书,而不是关掉校验。 - 把 MAC 当签名用,或把签名当 MAC 用。 为什么错:MAC 由共享密钥生成,争议时无法归属(法官无法裁决);签名用私钥生成,但性能与密钥管理成本高,且大量高频消息签名会拖垮吞吐。正确做法:会话内数据用 MAC/AEAD,跨组织、跨时间、需要归属的证据用签名(25.4 代码 1 的
[7]段落把两者的差别实测了出来)。 - 忘记新鲜度:以为加密或”票据合法”就能防重放。 为什么错:攻击者无需读懂密文就能原样重发;Kerberos 的票据、JWT、TLS 的 0-RTT 早期数据都是可重放的。正确做法:为每条”有副作用”的请求提供 nonce、序列号、时间戳 + 重放缓存或幂等键——这与 RPC 一章讲的”重复请求过滤/幂等操作”是同一套工具。
- nonce / IV / 随机数重用或可预测。 为什么错:同一 $(K, \text{nonce})$ 在 GCM 下直接泄露两段明文的异或;CBC 的 IV 可预测会泄露明文相等关系;ECDSA 的 $k$ 重用会直接暴露私钥。正确做法:用 CSPRNG(
secrets/os.urandom)、计数器型 nonce 并持久化状态、给密钥设置使用上限与轮换周期,签名优先用确定性的 Ed25519。 - 自己实现密码学或自己设计协议。 为什么错:计时侧信道、填充预言、MAC-then-encrypt 的顺序错误、长度扩展、随机数质量——这些失败模式极其隐蔽且不体现在”测试通过”上。正确做法:用经过审计的库与标准协议;需要”组合”时也只组合标准构件(TLS 1.3、Noise、JOSE 的成熟配置),并避免用 ECB、MD5、SHA-1、裸 RSA。
- 依赖时钟却不保护时钟。 为什么错:Kerberos 的 $\pm 5$ 分钟窗口、证书有效期、租约/fencing、TOTP 全部以时间为信任基础;NTP 欺骗既能造成”全员认证失败”的 DoS,又能放大重放窗口。正确做法:使用认证的 NTP(NTS)、多源交叉校验与漂移监控、把时间源列入 TCB 与审计范围,并在设计上尽量避免把安全性质完全押在时钟上(保留重放缓存、优先 nonce)。
- 只在边界做安全(”内网可信”)或以为零信任是产品而不是架构。 为什么错:边界一旦被突破(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;同时明确写下”我们容忍哪一类故障、在哪一层容忍、以及攻击者要付出多大代价才能越界”。
