认证要证明的是「你知道什么、你有什么、你是谁」,而不是「你说了什么」。 所以一次发过去就不会变的字符串,天生不是好凭证。
上一节解决了「别人看不见」,但加密只保证「中间人读不到」,保证不了「对面不是中间人」。 Demo 把三种做法放在一起跑:固定口令、挑战应答、一次性口令。
一、三种因子,缺一不可
认证因子只有三类,所有认证手段都是这三类的某种组合:
| 因子 | 是什么 | 例子 | 弱点 |
|---|---|---|---|
| 所知 | 脑子里记的 | 口令、PIN | 会被偷看和撞库 |
| 所有 | 身上带的 | 手机、U 盾 | 会丢、会被复制 |
| 所是 | 身体特征 | 指纹、人脸 | 改不了,泄露就是永久 |
所谓「双因子」必须跨类: 密码加密码提示问题,这不叫双因子,叫两道同类的题——破解了一个思路,两个都能破。 密码加手机验证码才是双因子,因为它要求「知道」和「拥有」同时成立。 第三行的弱点最容易被忽略:口令泄露了可以改,指纹泄露了改不了。 所以生物特征只能当「用户名」用,不能单独当「密码」用—— 它适合证明「你是你」,不适合证明「你被允许」。
二、从固定口令到挑战应答
固定口令有三个问题,一个比一个难缠: 一是它在链路上走明文,抓包就得到;二是它可以被重放,抓一次就能用一辈子; 三是它会被人反复用,一个站泄露,别的站跟着完蛋。 第二个问题最要命,因为它连「破解」都不需要。 解决办法是「让每次发出去的东西都不一样」,也就是挑战应答: 服务器先给一个随机数,客户端回一个「用密钥和这个随机数算出来的值」。 这套东西的关键在「随机数每次都是新的」: 抓到的旧应答配不上新挑战,算出来的值对不上,于是重放失效。 它还有个好处:密钥本身从不上链路,所以哪怕被全程录音,也拿不到密钥。 服务器那一侧还有一件必做的事:口令不能明文存。 正确做法是「加盐哈希」——给每个用户一个随机盐,存的是「盐加密码」的哈希值。 这样两个用户用同一个密码,存下来的值也不一样,而攻击者拿不到彩虹表直接反查。
三、数字签名与证书
数字签名是公钥密码的另一个用法,方向正好相反: 加密是「用公钥锁、用私钥开」,签名是「用私钥签、用公钥验」。 所以签名同时证明了两件事:「这东西确实是你发的」,因为只有你有私钥; 「这东西没被改过」,因为改一个字节签名就对不上。 这两个恰好是「认证」和「完整性」,一个动作干了两件事。 签名之前一定要先哈希,原因有两条:一是快,公钥运算慢,对摘要签名等于只算一次小块; 二是定长,无论原文多大,签名长度都一样。 所以「签名」在实现上永远是「对摘要签名」, 这也解释了为什么哈希函数被攻破会连带签名一起失效。 但签名还有一个前提没解决:验签的人怎么知道这把公钥是你的? 攻击者完全可以自己生成一对钥匙,然后声称那是你的。 证书就是来补这一环的:它是一张「公钥和身份绑在一起」的证明,由第三方 CA 签名。 所以信任链是这样建立的:你信任根证书(预装在系统里),根签中间,中间签站点。 整条链验通,才说明「这把公钥真的属于这个名字」。
四、动手 Demo:固定口令会被重放,挑战应答不会
三种做法各跑一遍:固定口令看两次发送是否一样;挑战应答看重放为什么失败; 一次性口令看时间怎么变成六位数。
import hashlib, hmac, os, struct
secret = b"correct horse battery staple"
# 一、固定口令:两次一模一样,抓一次就能重放
print(secret == secret)
# 二、挑战应答:每次挑战都是新的随机数
n1, n2 = os.urandom(8), os.urandom(8)
r1 = hmac.new(secret, n1, hashlib.sha256).hexdigest()[:16]
print(hmac.compare_digest(r1, hmac.new(secret, n1,
hashlib.sha256).hexdigest()[:16])) # 新应答,通过
print(hmac.compare_digest(r1, hmac.new(secret, n2,
hashlib.sha256).hexdigest()[:16])) # 重放旧应答,失败
# 三、TOTP:把时间片当挑战
c = 1770000013 // 30
mac = hmac.new(secret, struct.pack(">Q", c), hashlib.sha1).digest()
off = mac[-1] & 0x0F
code = struct.unpack(">I", mac[off:off + 4])[0] & 0x7FFFFFFF
print("%06d" % (code % 1000000))
实跑输出:
--------------------------------------------------------------------
固定口令会被重放,挑战应答不会
--------------------------------------------------------------------
**认证要回答的是「你是不是你」。**
**最笨的办法是把口令发过去比对,但这条口令在链路上走一次就多一次被复制的机会。**
**下面把三种做法放在一起跑。**
**做法一:固定口令。**
**两次登录在链路上抓到的字节完全一样**:`correct hors…`
**所以旁观者只要复制一次,就能永远冒充下去**——**这叫重放攻击,它连「破解」都不需要。**
**做法二:挑战应答。**
轮次 服务器给的随机数 客户端回的哈希 结果
--------------------------------------------------------------------------------
**第一次** `19b214b19702e019` `bf03e9142166a08d` **通过**
**第二次** `ae5f322d5c2b5016` `52bbea9c39104adf` **通过**
**重放第一次的应答** `ae5f322d5c2b5016` `bf03e9142166a08d` **拒绝**
**关键在第二列**:**每次挑战都是新的随机数,所以应答每次都不一样,抓到了也没用。**
**重放那一行说明的是同一件事**:**旧应答配新挑战算不出同样的值**,
**服务端两次校验的结果一个是「通过」,一个是「失败」。**
**这套东西有个前提**:**双方共享同一个密钥。**
**所以它只能用在「已经有一份共同秘密」的场景**,**跨网的第一把钥匙怎么送过去,是另一个问题。**
**做法三:一次性口令(TOTP)。**
**把「时间」当挑战**:**每 30 秒换一个数,两边各算各的,不用来回传。**
时刻 时间片号 六位口令
---------------------------------------
1770000013 59000000 **795554**
1770000043 59000001 **418593**
1770000073 59000002 **797193**
**三行的口令互不相同,而且谁都算不出来**——**除非他知道那个共享密钥。**
**这就是手机验证器的工作原理:手机和服务器各有一个密钥,各自按当前时间算同一个六位数。**
**它的代价也很清楚**:**时钟必须准**,**偏移超过一个时间片就全错**;
**而且口令一旦被看到,30 秒内仍然可用**——**这是「一次一密」和「方便」之间的折中。**
第一段的两行字节完全一样,这就是重放攻击的入口。
旁观者只要复制一次,就能永远冒充下去,他甚至不需要知道口令是什么。
第二段的结果是「一个通过、一个失败」:
新挑战算出来的值和发出去的应答一致,所以通过;旧应答配新挑战算出来的值不同,所以失败。
注意代码里用的是 compare_digest 而不是 ==——
普通的字符串比较会在第一个不同的字节处提前返回,
攻击者靠测量时间就能一个字节一个字节地试出来,这叫计时攻击。
第三段的六位数是算出来的,不是随机数:手机和服务器各有一个密钥,各自按当前时间算同一个数,
所以不需要联网、不需要传输,两边就能对上。
代价是时钟必须准——偏移超过一个时间片,算出来的就是另一个六位数了。
30 秒实验
- 算一下:六位数字口令有多少种?(一百万种,而手机验证器 30 秒就换一个)
- 想一下:为什么「密码 + 密码提示问题」不算双因子?
- 想一下:为什么签名之前一定要先哈希?
常见误区
- 以为「加了密就不用认证」。 加密防的是「被看见」,防不了「对面是假的」。
- 以为「一次性口令就绝对安全」。 它防重放不防实时钓鱼,30 秒内被转走一样有效。
- 以为「生物识别最安全」。 它泄露后改不了,只适合当「你是谁」,不适合当「你被允许」。
自测三问(点开对答案)
1. 双因子的「双」是什么意思? —— **必须跨类**:知道的东西和拥有的东西同时成立。 2. 挑战应答为什么能防重放? —— **每次的随机数都是新的**,旧应答配新挑战算不出同样的值。 3. 证书解决了什么问题? —— **把公钥和身份绑在一起**,由 CA 背书,否则谁都能自称是你。下一节把前面所有零件装起来:看 IPsec 怎么在网络层做一整套。
- ☐ (本节完,下一节 9.4.1 IPsec与VPN)