令牌与 HMAC · 网络安全入门
服务器怎么知道 Cookie 没被用户改过?
课程目录为什么需要它
登录之后,服务器会给浏览器一个令牌(通常放在 Cookie 里),之后每次请求都靠它识别你。可浏览器是用户自己的,里面存的东西用户都能改。签名令牌、不可预测的会话 ID 和谨慎的比较方式,才能阻止别人把 role=user 改成 role=admin。
直觉理解
想象信封上的火漆印。信谁都能读,但印章只有寄信人有;印章被拆过或者是假的,一眼就能看出来。
HMAC 就是数据的火漆印:hmac.new(SECRET, data, sha256) 把只有服务器知道的密钥混进数据的哈希里。服务器发出 data|tag。令牌回来时,服务器重新算一遍 tag;用户哪怕改了一个字符,tag 也对不上,而没有密钥就造不出新的有效 tag。
还有两个细节:
- 比较 tag 要用
hmac.compare_digest:无论第一个还是最后一个字符不同,它花的时间都一样。普通的==一遇到不同就停下,这点细微的时间差可能把 tag 一点一点泄露出去。 - 会话 ID 必须不可预测:用
secrets.token_hex(),不要用random。random的设计目标是快和可重现,而不是保密。
定义
HMAC 消息认证码
A hash of the data mixed with a secret key. Anyone can check it only if they know the key, so it proves the data came from the key owner and wasn't changed.把数据和秘密密钥混在一起算出的哈希。只有知道密钥的人才能验证它,所以它能证明数据出自密钥持有者,并且没被改过。
时序攻击 Timing attack
Learning a secret by measuring how long a program takes, for example a comparison that returns sooner when the first characters are wrong.通过测量程序运行时间来推测秘密的攻击,比如前几个字符错了就会更早返回的比较。
示例
给令牌签名并验证
动手试试用户能看到令牌内容,但一改签名就对不上了。
import hmac, hashlib
SECRET = b"server-only-key"
def sign(data):
tag = hmac.new(SECRET, data.encode(), hashlib.sha256).hexdigest()
return data + "|" + tag
def verify(token):
data, tag = token.rsplit("|", 1)
good = hmac.new(SECRET, data.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(tag, good)
t = sign("user=amy;role=user")
print(t[:40] + "...")
print(verify(t))
print(verify(t.replace("role=user", "role=admin")))- L2只有服务器知道这个密钥。真正的密钥又长又随机。
- L5tag 同时取决于密钥和数据的每一个字符。
- L9
rsplit("|", 1)只在最后一个竖线处拆开。 - L15没动过的令牌:tag 对得上。
True - L16被改过的令牌:tag 和数据对不上了。
False
为什么 == 会泄露
动手试试这个比较函数会数自己检查了几个字符才停下。猜对的越多,步数越多,耗时也越长。
def naive_equal(a, b):
steps = 0
for x, y in zip(a, b):
steps += 1
if x != y:
return False, steps
return len(a) == len(b), steps
secret = "7f3a9c"
for guess in ["000000", "7f0000", "7f3a00"]:
print(guess, naive_equal(secret, guess))- L3
zip把两个字符串的字符一一配对。 - L6遇到第一个不同就停下。
- L11一开始就错:1 步。
000000 (False, 1) - L11前两个字符对了:3 步。测量时间的攻击者就知道前缀猜对了。
7f0000 (False, 3)
常见错误
用 random 生成秘密
random 生成的数可以根据之前的输出推算出来。令牌、重置码、会话 ID 都要用 secrets 模块。
用 == 比较秘密
字符串的 == 遇到第一个不同就停。比较 tag、令牌和密码哈希时,用 hmac.compare_digest。
小测验
1. 这段代码会输出什么?
def naive_equal(a, b):
steps = 0
for x, y in zip(a, b):
steps += 1
if x != y:
return steps
return steps
print(naive_equal("abcd", "abzz"), naive_equal("abcd", "abcd"))查看答案
答案:A
a、b 都对上,第 3 步 c 和 z 不同。完全相同的字符串会比完 4 个字符。
2. 令牌 `user=amy|<tag>` 用 HMAC 签过名。用户把它改成 `user=bob|<tag>`,会怎样?
查看答案
答案:B
tag 是用 user=amy 和密钥算出来的。服务器对 user=bob 重新计算,得到不同的值,于是拒绝。没有密钥,用户造不出正确的 tag。
动手练习
3 道练习,分三层:先预测程序的输出,再按新需求改程序,最后从零写一个。代码在浏览器里运行,提交马上判题。
- 预测输出哪些令牌能通过?
准确写出这段程序的输出。
- 改需求更稳的验证
改进
verify(token):- 用
hmac.compare_digest代替==来比较 - 令牌里没有
"|"时返回False,而不是直接报错
- 用
- 从零写会过期的令牌
密码重置链接过一段时间就该失效。写两个函数:
make_token(user, expires)返回user + "|" + str(expires) + "|" + tag,其中tag是对文字user + "|" + str(expires)算的 HMAC(和sign里一样)。check_token(token, now):如果 tag 有效并且now不大于expires,返回用户名;否则返回None。
expires和now都是整数(秒)。用hmac.compare_digest比较。
本课术语
| 术语 | 中文 | 英文定义 |
|---|---|---|
| HMAC | HMAC 消息认证码 | A keyed hash that proves data was produced by someone holding the key and wasn't changed. |
| Session id | 会话 ID | A random value that identifies a logged-in user between requests. |
| Timing attack | 时序攻击 | Learning a secret from how long an operation takes. |
| secrets module | secrets 模块 | Python's module for generating unpredictable tokens. |