DIGEST 认证让密码不上线:服务端给一个 nonce, 客户端用「密码 + nonce + 请求信息」算一个摘要发过去。 它解决了明文传输,但没解决重放,也没解决「服务端得知道密码」这件事。
上一节把密码送上去了,这一节想办法不送。思路和 7.1.3 的 HMAC 一样:比摘要,不比原文。
一、挑战—应答的三步
第一步服务端回 401,挑战里带四项:realm、nonce、qop=auth、algorithm=MD5。
nonce 是一次性随机串,Demo 里是 16 个十六进制字符。
第二步客户端算两个中间值:HA1 = MD5(账号:realm:密码)、HA2 = MD5(方法:URI),再把它们和 nonce、
nc、cnonce、qop 一起摘要:response = MD5(HA1:nonce:nc:cnonce:qop:HA2)。
第三步把 username、realm、nonce、uri、qop、nc、cnonce、response 一起发回去。
服务端拿自己存的密码算一遍,对得上就放行。
注意发出去的那一整行里没有密码。这是它比 BASIC 强的地方。
二、它没解决的三件事
一、服务端还是得知道密码原文。
要算 HA1 就得有 账号:realm:密码,所以服务端要么存明文,
要么存 HA1(等于把密码变成了另一个可直接用的凭据)。这一点比「存加盐哈希」还糟。
二、算法是 MD5。
Demo 里算出来的 response 是 32 个十六进制字符,看着挺像样,但 MD5 早就能造碰撞,强度不够。
三、挡不住重放。
攻击者拿到这一整行 Authorization,原样再发一次,服务端算出来的摘要一样对得上。
nc 计数器和 cnonce 本来想解决这个,但要求服务端记状态,实际实现里大多没做。
三、一个容易写错的地方
密码错的时候回 401,但不该带 stale=TRUE。
stale 的意思是「你的摘要格式没错,只是我这个 nonce 过期了,
拿新的重算」——它告诉客户端「别改密码,换个 nonce 再试」。
而密码错是「摘要就是不对」,混用会让客户端白试一轮。
这类细节在规范里写得很清楚,但实现时容易顺手复制粘贴。
排查「登录一直失败」的问题时,先看服务端回的挑战里带没带 stale。
四、动手 Demo:一次挑战,一次应答,两个摘要对上了
用纯 Python 实现服务端和客户端两侧的摘要计算,跑一遍完整的挑战应答; 再把密码换掉算一次,看摘要怎么变。
def ha1(name, realm, pw):
return hashlib.md5((u'%s:%s:%s' % (name, realm, pw)).encode()).hexdigest()
def ha2(meth, uri):
return hashlib.md5((u'%s:%s' % (meth, uri)).encode()).hexdigest()
def resp(name, realm, pw, meth, uri, nc, cnonce, qop):
raw = u'%s:%s:%s:%s:%s:%s' % (ha1(name, realm, pw), nonce,
nc, cnonce, qop, ha2(meth, uri))
return hashlib.md5(raw.encode()).hexdigest()
实跑输出:
--------------------------------------------------------------------
DIGEST:密码不上线,只发摘要
--------------------------------------------------------------------
第一次请求,服务端给一个挑战:
HTTP/1.1 401 Unauthorized
Digest realm="secret-zone", nonce="9d0aa7cbd5f69166", qop="auth", algorithm=MD5
客户端按这个公式算摘要(密码**不**出现在请求里):
HA1 = MD5(账号:realm:密码) = 0a9e51ac79f6262cf764ea3dc6dc95e5
HA2 = MD5(方法:URI) = 4541216a6e9025c450063a950cc05d95
response = MD5(HA1:nonce:nc:cnonce:qop:HA2)
= 3f3300d9508084ca53e9dfd6a0c53649
发出去的 Authorization 是这一串:
Digest username="bozai", realm="secret-zone", nonce="9d0aa7cbd5f69166", uri="/secret", qop=auth, nc=00000001, cnonce="600aefccb55398f6", response="3f3300d9508084ca53e9dfd6a0c53649"
里面没有密码,只有算出来的摘要。
服务端自己也算一遍,对得上就放行:
HTTP/1.1 200 OK
换一个错密码再算,摘要就完全不同:
正确密码 → 3f3300d9508084ca53e9dfd6a0c53649
错误密码 → 712fc4c68b4a5b221727b538cd8a5e77
服务端比对不上,回 401。注意这里**不该**带 stale=TRUE——
stale 的意思是「你的摘要没错,但我这个 nonce 过期了,拿新的重算」,
密码错属于「摘要就是不对」,混用会让客户端白试一轮。
DIGEST 解决了「密码上线」,但没解决全部:
一、它依赖服务端**存明文密码**(要算 HA1 就得有原文),
这一点比存哈希还糟,所以现代实现宁可存 HA1 也不存原文;
二、摘要算法是 MD5,早就不安全;
三、它挡不住中间人——攻击者拿到这一串摘要,照样能重放。
所以 DIGEST 实际用得很少,主流是「HTTPS + 表单登录 + 会话 cookie」。
挑战里四项:realm="secret-zone"、nonce="a5ee9838664ce128"、qop=auth、algorithm=MD5。
算出来 HA1 是 0a9e51ac...、HA2 是 4541216a...、response 是 7fa657ad...。
服务端用同样的输入算一遍,一致,回 200。
换错密码重算,response 变成 d671d37b...,对不上,回 401。
两个值只差一点输入,输出完全无关——这正是哈希该有的样子。
发出去的那一整行长 100 多字节,里面一个密码字符都没有。这是 DIGEST 的全部价值,也是它唯一的进步。
30 秒实验
- 把 nonce 固定成同一个值,连发两次同样的请求,看服务端会不会都放行(重放)。
- 把算法从 MD5 换成 SHA-256,看摘要长度变成多少。
- 想一想:如果服务端只存 HA1 而不存密码,安全性比存密码好还是差?
常见误区
- 「DIGEST 就不用 HTTPS 了」——摘要能被重放,仍需加密保护。
- 「DIGEST 服务端不用存密码」——要算 HA1 就得有密码或等价的 HA1。
- 「密码错要回 stale=TRUE」——stale 表示 nonce 过期,不是密码错。
自测三问(点开对答案)
1. DIGEST 认证发出去的凭据里有密码吗? —— 没有,只有按公式算出的摘要。 2. 它为什么挡不住重放? —— 整行 `Authorization` 可以原样重发,服务端算出的摘要一样对得上。 3. `stale=TRUE` 是什么意思? —— 摘要格式没错,但 nonce 过期了,让客户端用新 nonce 重算。两种基于密码的方案都有软肋。想更强,就得加上「持有」这一类凭据——客户端证书。
- ☐ (本节完,下一节 8.4.1 SSL客户端认证的认证步骤)