蓝图 · 273 字 · 1 分钟阅读

数字签名:把公钥加密反着用

你用私钥签,任何人都能用公钥验。这一点不对称,支撑着软件更新、TLS 证书、git 提交,以及每一个必须在不信任信道的情况下信任代码的系统。

#TL;DR

RSA 在一个出人意料的地方是对称的:公钥和私钥在数学上可以互换角色。用公钥加密、私钥解密给你保密性;反过来——用私钥”加密”、公钥”解密”——给你真实性:任何人都可以验证这个结果只可能由密钥拥有者产出。这就是数字签名。你不直接对文档签名,你签它的一个哈希。这个原语支撑着 TLS 证书、苹果签名的构建、Debian 软件包、JWT、git 提交签名、加密货币交易,以及所有”不被信任的代码必须先验证再运行”的地方。

#剥掉外衣的原语

RSA 那篇展示了两个方向:

# 加密:公钥锁、私钥开
ciphertext = pow(message, e, n)
plaintext  = pow(ciphertext, d, n)

# 签名:私钥锁、公钥开
signature  = pow(document_hash, d, n)
verified   = pow(signature, e, n)

数学是一样的,变的是方向,以及关键的——这个结果意味着什么。

当你用别人的公钥加密,你在说:“我只希望你能读到这个。” 当你用自己的私钥签名,你在说:“我希望所有人都能验证这是我产出的。” 同一对密钥可以干这两件事,因为模 n 下的幂是可交换的:(m^e)^d ≡ (m^d)^e (mod n)

这是 RSA 独有的。不是每一个公钥系统都这样对称——Diffie-Hellman 纯粹是密钥协商协议,不能直接签名。建立在其他数学上的签名(DSA、ECDSA、EdDSA)是不同的原语,只是恰好提供同样的保证,并不是字面意义上”反着用的 RSA”。

#为什么签哈希,而不是签文档

RSA 原语只对比 n 小的数有效。对 2048 位 RSA 来说,这意味着 256 字节。真实文档远大于此。

你可以把文档切块、每块都签,但这样慢(每 256 字节做一次模幂),而且——更关键——这并不能证明你想证明的东西。给块签名只证明你签了每一块,并不证明你签的就是这些块、按这个顺序。攻击者可以重排、丢掉一些、或混入从别的文档里拿来的已签块。

修法是先哈希再签:

  1. 对整个文档算一个加密哈希——一个定长的指纹(通常 256 位或 512 位),文档任何一位改变,哈希就完全不同。
  2. 对这个哈希签名。一次模幂,文档多大都一样。
import hashlib

def sign(document_bytes, d, n):
    h = int.from_bytes(hashlib.sha256(document_bytes).digest(), 'big')
    return pow(h, d, n)

def verify(document_bytes, signature, e, n):
    h = int.from_bytes(hashlib.sha256(document_bytes).digest(), 'big')
    return pow(signature, e, n) == h

文档里哪怕一位改变,都会产生一个完全不同的哈希,对应一个完全不同的数去验证。签名实际上是对这份精确文档的一个承诺,而不只是对签名时刻内容的承诺。

(上面是教科书 RSA。真实签名方案会用结构化的填充——RSA-PSS——来防止针对裸哈希-签名的一类攻击。思路一样,做得更小心。)

#哈希函数到底承诺什么

数字签名只能和它所用的哈希函数一样牢。哈希要具备三条性质:

  • 抗原像。 给定哈希 h,找到某个消息 m 使得 hash(m) = h 在计算上不可行。如果没有这个性质,攻击者就能伪造一个文档去匹配已有签名。
  • 抗第二原像。 给定消息 mhash(m),找到另一个不同的消息 m' 也有同样哈希在计算上不可行。没有这个,给攻击者一份已签文档,他能生成另一份具有相同签名的文档。
  • 抗碰撞。 找到任意一对 m, m' 使得 hash(m) = hash(m') 不可行。没有这个,攻击者可以提前构造两份哈希相同的文档,让你签其中一份,然后声称你签了另一份。

抗碰撞是最强、也是最容易破掉的。哈希函数的履历本身就是个故事:

  • MD5 — 128 位,已破。2004 年展示了碰撞,2008 年被武器化用来攻击 Windows 代码签名(Flame 恶意软件),现在只用来做非安全校验和。
  • SHA-1 — 160 位,已破。Google 2017 年公开了碰撞(“SHAttered” 论文)。用 SHA-1 的 Git 在逐步迁移。
  • SHA-256、SHA-512 — SHA-2 家族,目前安全。现代签名多数都用它。
  • SHA-3 — 另一种构造(基于 Keccak),2015 年被标准化,作为 SHA-2 万一出事的保险。实际上 SHA-2 也没出事,SHA-3 很少是默认,但可用。
  • BLAKE2、BLAKE3 — 现代替代品,在大多数硬件上比 SHA-2 快,被认为安全。

SHA-1 被破之后,每一个在证书哈希上签 SHA-1 的协议突然都有了迁移问题。签名本身仍然是密码学上正确的——但底下的哈希不是,签名的保证因此无效。“我们的签名方案用 SHA-1” 这句话导致了好几年的紧急协议更新。

#证书链:通过签名建立信任

你每次打开网页都会跟它打交道的最常见数字签名,就是 TLS 证书。链条是这样的:

根 CA 证书(DigiCert、Let's Encrypt 等)

   │  签

中级 CA 证书

   │  签

服务器证书(比如 example.com)

   └─ 里面有服务器的公钥

你的浏览器附带一份受信根证书颁发机构清单——大约 100 份根证书,烧录在操作系统或浏览器里。当你连 example.com 时:

  1. 服务器发来自己的证书,里面有它的公钥、域名和一段签名。
  2. 这段签名是用某个中级 CA 的私钥产出的;浏览器用那个中级 CA 的公钥(它也在证书链里)验证。
  3. 中级 CA 的证书由根 CA 签名,根 CA 的公钥浏览器本来就信。
  4. 每一段签名都能验证成功 → 浏览器相信服务器的公钥属于 example.com → TLS 可以继续。

证书链上每一环都是对”证书内容的哈希”的数字签名。断掉任何一环,链就断了。整个 PKI——互联网的公钥基础设施——就是一棵巨大的 RSA 或 ECDSA 签名树,根源是大约 100 把被浏览器钦定信任的密钥。

#代码签名

每次你的手机装一个应用,都会验证一段签名:

  • iOS 和 macOS。 每个应用由苹果开发者证书签名,而且苹果自己会再用自己的密钥给每一个 App Store 应用重新签一遍。操作系统默认拒绝运行未签名的代码,除非你明确覆盖。“公证”(Notarization)再加一段来自苹果的签名,确认这个应用已通过恶意软件扫描。
  • Windows。 代码签名是可选的,但被强烈激励——未签名的安装包会引发很可怕的 UAC 提示。内核驱动则必须用签名证书。
  • Android。 APK 由开发者签名,安装时验签。更新必须用和原版一样的密钥签名;这就是为什么某个恶意开发者没法发个”看起来一样”的伪装更新。
  • Debian、Fedora、Arch。 发行版里每一个软件包都由发行版信任的密钥签名。aptdnf 拒绝安装签名验证不过的包。

共同的模式是:不要运行代码,除非你信任的某个人签过它。 签名不证明代码是好的——一把被攻破的开发者密钥给坏代码签名,和给好代码签名,一样毫无障碍——但它证明来源。你知道你跑的是谁家的 bug。

这是互联网在软件分发上落脚下来的模型,之后也被从各种角度攻击过。供应链攻击(SolarWinds、XZ Utils 后门、3CX)全都带着合法签名,因为攻击者攻破了签名密钥。签名的保证是结构性的,不是语义性的——它证明是谁,不证明是什么。

#Git 与 JWT

两个每天都能看到签名的更低层场景:

  • 已签名的 git 提交和标签。 git commit -S 用你的 GPG 或 SSH 密钥给 commit 对象签名。任何有你公钥的人都能验证这个提交是你做的。GitHub 会亮一个”Verified”徽章。git 存在的头十五年这件事基本被忽视,后来大家意识到”commit 作者”和”真正做 commit 的人”不是同一件事、未签名 commit 可以被轻易伪造,才重视起来。
  • JWT(JSON Web Token)。 结构是 base64(header).base64(payload).base64(signature)。签名是对 header.payload 的数字签名——通常是 RS256(RSA-SHA256)、ES256(ECDSA-SHA256),或 HS256(HMAC-SHA256 配共享密钥,严格不算公钥签名,但结构一样)。一张签名合法的 JWT 是一份自带验证手段的凭证,服务器不用再查任何东西就能验它。

JWT 是一个很好的例子,说明签名原语如何改变一个系统的信任模型。没有签名,一次会话验证要查数据库(“这个会话 ID 合法吗?”);有了签名,会话自描述、可被验证,因为 token 本身就带着”这是服务器签发的”这份证明。

#签名不做什么

数字签名被广泛误解。它明确做两件事:

  • 它不加密。 一份已签文档任何人都能读。如果你需要内容保密,就得另外加密,用另一把密钥(通常还是另一套密码学——用 RSA 签,用 AES 加密)。
  • 它不打时间戳。 签名证明是谁签的,不证明什么时候签的。要拿到”在时间 T 签的”,你需要一个独立的时间戳权威——一个受信任的第三方,在签名时对 (document_hash, timestamp) 再签一遍。没有它,将来拿到你密钥的攻击者,能做出看上去合法、但事实上是”回填日期”的签名。

它也不证明意图。签名证明的是”密钥在被使用时在你手里”,不证明你知道自己在签什么、也不证明你读过文档、甚至不证明你意识到自己正在签。这就是为什么硬件安全令牌(YubiKey、智能卡)往往要求你按一下物理按钮——这是对”意图”很弱的证据,但至少是点什么。

#更大的模式

RSA 那篇把数字签名称作”RSA 的第二招”。这对 RSA 是准确的,却低估了它的普适性。数字签名是现代密码学两三个基础原语之一,而且出现在和”加密消息”几乎无关的许多地方:

  • 区块链交易 — 每笔比特币交易都是一连串 ECDSA 签名,用来证明所有权。
  • DNSSEC — DNS 记录被签名,这样你可以验证它们在权威服务器和你的解析器之间没有被篡改。
  • 软件更新 — 自动更新器拒绝安装未签名的更新。
  • SSH。 即便是服务器身份验证(known_hosts),也是针对服务器已签名的主机密钥去核对。
  • 证书透明日志 — 每一张被签发的证书都追加写入的只读日志,每条日志条目都带签名,这样任何流氓 CA 都无法在暗中悄悄签发证书。

这个原语简单得不像话:一把密钥用来签,另一把用来验,中间夹一个哈希。在一个对抗性环境里所有”要先验证数据出处”的系统,都是这个简单想法的下游消费者——四十九岁高龄,发端于 MIT 某个四月的早晨,至今仍在做它的本职工作。