多因素与 无密码未来
Phase 1 的最后一天。前四天我们围着"密码 + 会话"这一套打转, 但密码这件事本身存在根本缺陷—— 它能被钓鱼、能被脱库、能被复用、能被猜中。 Day 05 的目标是看清两条出路: 短期方案 —— TOTP 给密码加一道时间因子; 长期方案 —— WebAuthn / Passkey 彻底干掉密码, 用公私钥让钓鱼网站从原理上无法骗到凭证。 看完这一天, 你应该能给自己的服务接 TOTP, 也能解释为什么 Apple / Google / 微软都 All-in Passkey。
思维导图
MFA 三要素与威胁模型
讲 MFA 之前必须先讲清"什么是 factor"。 所谓"多因素", 不是"输入两个东西"—— 输密码 + 输安全问题, 仍然是单因素 (都是"你知道的"), 攻击者一次钓鱼能同时拿到。 真正的多因素要求两类来源不同的凭证, 让攻击者必须同时攻破两个独立通道。
三类因子
你知道什么
密码、PIN、安全问题、图形手势。问题: 能被钓鱼、能被脱库、会被复用——大约 80% 的账号入侵都从这里开始。
你拥有什么
手机(收 SMS / TOTP App)、硬件安全密钥(YubiKey)、智能卡。关键: 必须物理或加密地绑定到设备, 不能远程复制。
你是什么
指纹、Face ID、虹膜、声纹。注意: 生物特征作为"本地解锁手段"很安全, 但不应该直接传到服务端——一旦泄露无法换。
2FA / MFA / Step-up 概念辨析
| 术语 | 定义 | 典型场景 |
|---|---|---|
| 2FA | 恰好两个因素 (Two-Factor) | 密码 + TOTP / 密码 + Passkey |
| MFA | ≥2 个不同来源的因素 (Multi-Factor) | 2FA 是 MFA 的子集; 高安全场景可叠到 3 因素 |
| Step-up Auth | 按风险动态要求加一道 | 登录用密码; 转账时再要 TOTP / Passkey |
| Adaptive MFA | 风险引擎判断要不要要二次因素 | 异地登录 / 异常设备 → 强制 MFA; 常用设备跳过 |
| Passwordless | 没有密码这个因素, 直接用其他因素登录 | Passkey only / Magic Link / 手机推送 |
哪些 MFA 能抵抗钓鱼?
| 方案 | 因素 | 抗钓鱼? | 原因 |
|---|---|---|---|
| 密码 + 安全问题 | 单因素 (都是"知道") | ✗ | 都能在同一个钓鱼页输入 |
| 密码 + SMS OTP | 知 + 有 | ✗ | 实时钓鱼代理可转发, 用户感觉不到 |
| 密码 + Email OTP | 知 + (邮箱另一密码) | ✗ | 同上 + 邮箱本身也是密码体系 |
| 密码 + TOTP App | 知 + 有 | ✗ | 30s 内仍可被实时钓鱼转发 |
| 密码 + 推送审批 | 知 + 有 | 部分 | 会被"MFA 疲劳轰炸"绕过 (反复推送让用户失误点确认) |
| 密码 + WebAuthn 硬件 | 知 + 有 (加密绑定) | ✓ | Origin-bound · 钓鱼站发不出正确挑战 |
| Passkey only | 有 + 是 (设备生物解锁) | ✓ | 无密码可钓, 公私钥+Origin 绑死 |
TOTP / HOTP 算法详解
Google Authenticator / Authy / 1Password 里那串"30 秒变一次"的 6 位数字, 背后只是两条 RFC—— RFC 4226 (HOTP) 和 RFC 6238 (TOTP)。 公式简单到一页纸说得完, 但理解它你才能解释清楚: 为什么用 SHA-1 仍然是安全的; 为什么必须容忍时钟偏移; 为什么 6 位刚好够。
HOTP 公式 (RFC 4226 · 计数器型)
# HOTP(K, C) = Truncate( HMAC-SHA1(K, C) ) mod 10^Digits # 输入 K // 共享密钥 (≥ 16 字节, 通常 20 字节) C // 8 字节计数器 (大端编码) Digits // 输出位数, 通常 6 # 步骤 1) HS = HMAC-SHA1(K, C) // 输出 20 字节 2) offset = HS[19] & 0x0F // 取末字节低 4 位 (0..15) 3) bin = (HS[offset] & 0x7F) << 24 | (HS[offset+1] & 0xFF) << 16 | (HS[offset+2] & 0xFF) << 8 | (HS[offset+3] & 0xFF) // 动态截断 4 字节 4) return bin mod 10^Digits // 6 位 → mod 1_000_000
TOTP 公式 (RFC 6238 · 时间型)
# TOTP = HOTP(K, T) # 把 HOTP 里的"计数器"换成"时间步长" T = floor( (Now - T0) / X ) # Now: 当前 Unix 时间 (秒) # T0: 起始时间, 通常 0 # X: 时间步长, 通常 30 秒 # 然后照搬 HOTP TOTP = HOTP(K, T) # 例: 2026-05-26 10:30:00 UTC = 1779179400 秒 # T = floor(1779179400 / 30) = 59305980 # 把 59305980 作为 8 字节大端塞进 HOTP, 算出 6 位数字
TOTP 工作时序
otpauth URI 与 QR Code
用户绑定时不需要手敲密钥——服务端生成一个特殊 URI, 编成二维码, 用户用 Authenticator App 扫一下就把密钥导入到本地。
# otpauth URI 格式 (Google Authenticator 约定) otpauth://totp/{Issuer}:{Account}?secret={Base32}&issuer={Issuer}&algorithm=SHA1&digits=6&period=30 # 实际例子 otpauth://totp/Acme:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Acme&algorithm=SHA1&digits=6&period=30
Go 实战 · 手写 HOTP / TOTP
// hotp.go · 30 行实现 RFC 4226 package main import ( "crypto/hmac" "crypto/sha1" "encoding/binary" "fmt" "time" ) func HOTP(key []byte, counter uint64, digits int) string { // 1) 8 字节大端计数器 buf := make([]byte, 8) binary.BigEndian.PutUint64(buf, counter) // 2) HMAC-SHA1 mac := hmac.New(sha1.New, key) mac.Write(buf) h := mac.Sum(nil) // 20 字节 // 3) 动态截断 offset := h[19] & 0x0F bin := (uint32(h[offset])&0x7F)<<24 | uint32(h[offset+1])<<16 | uint32(h[offset+2])<<8 | uint32(h[offset+3]) // 4) 取低 N 位 mod := uint32(1) for i := 0; i < digits; i++ { mod *= 10 } return fmt.Sprintf("%0*d", digits, bin%mod) } func TOTP(key []byte, t time.Time, digits int) string { counter := uint64(t.Unix() / 30) return HOTP(key, counter, digits) } func main() { // RFC 6238 附录 B 测试向量: K = "12345678901234567890" (ASCII) K := []byte("12345678901234567890") t := time.Unix(59, 0) fmt.Println("T = 1 :", TOTP(K, t, 8)) // → 94287082 }
Go 实战 · pquerna/otp 生产用法
// 生产请用社区库, 自带 Base32 / QR / 时钟漂移容忍等细节 $ go get github.com/pquerna/otp/totp $ go get github.com/skip2/go-qrcode import ( "github.com/pquerna/otp/totp" qrcode "github.com/skip2/go-qrcode" ) // 1) 用户绑定: 生成密钥 + 返回 QR Code 图片 func bindTOTP(w http.ResponseWriter, r *http.Request) { key, _ := totp.Generate(totp.GenerateOpts{ Issuer: "OpenPlatform", AccountName: "alice@example.com", Period: 30, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1, }) // 关键: 服务端必须把 key.Secret() 存入用户表 (加密存储) saveSecret(currentUser, key.Secret()) // 返回 QR 给用户扫 png, _ := qrcode.Encode(key.URL(), qrcode.Medium, 256) w.Header().Set("Content-Type", "image/png") w.Write(png) } // 2) 登录二次验证: 用户输入 6 位码, 服务端校验 func verifyTOTP(w http.ResponseWriter, r *http.Request) { code := r.FormValue("code") secret := getSecret(currentUser) if !totp.Validate(code, secret) { http.Error(w, "invalid code", 401); return } w.Write([]byte("verified")) }
SMS / Email OTP 的真实风险
"收个验证码"是最被用户接受的二次验证方式, 但从 2016 年起 NIST 就建议弃用 SMS OTP(800-63B v3, §5.1.3.3)。 原因不是"算法弱", 而是手机网络这个传输通道本身就充满漏洞—— SIM Swap、SS7 信令攻击、运营商内鬼、实时钓鱼代理, 任何一条都能让验证码落到攻击者手里, 而用户和服务方都察觉不到。
SIM Swap 攻击
SMS / Email OTP 的攻击面汇总
| 攻击 | 原理 | 真实事件 |
|---|---|---|
| SIM Swap | 社工骗运营商把手机号转到攻击者 SIM | 2019 Twitter CEO Jack Dorsey · 2022 多起加密货币交易所盗号事件 |
| SS7 信令攻击 | 滥用 2G/3G 全球漫游信令协议拦截短信 | 2017 德国 O2 银行客户被批量盗号 |
| 运营商内鬼 | 客服 / 销售点员工被收买 | 2023 美国 T-Mobile 多起内部协助 SIM Swap |
| 实时钓鱼代理 | evilginx2 类工具实时转发 OTP 到真实站 | 2022-2024 大量企业 Microsoft 365 被钓鱼 |
| Email 账号本身被攻破 | Email OTP 等于"另一个密码", 邮箱被攻破就一锅端 | 所有以邮箱为 root of trust 的体系普遍问题 |
那应该用什么?
TOTP App
Google Authenticator / Authy / 1Password / Bitwarden 都内置, 用户接受度高, 服务端实现简单——开放平台最起码提供这个。
WebAuthn / Passkey
抗钓鱼 · 不可被远程攻击 · 用户体验比 TOTP 更好(Face ID 一秒)。新建系统的首选, 下一节展开。
SMS 仅做恢复
已经用了 SMS 的存量系统, 短期内难以完全去除——把 SMS 降级为"账号恢复手段", 主认证替换为 TOTP / Passkey。
WebAuthn / Passkey 实战
Passkey 不是"新的密码", 而是用 Day 02 的公私钥彻底取代密码—— 浏览器里按一下指纹 / Face ID, 设备用预先生成的私钥给服务端的挑战签个名。 整套体系的协议规范叫 WebAuthn (W3C Level 3), 它与硬件密钥的底层协议 CTAP2 (FIDO Alliance) 组合, 就是FIDO2。 Passkey 是 WebAuthn 凭证可以同步到云端的版本——Apple Keychain / Google 密码管理器替你同步。
WebAuthn 抗钓鱼的三个机制
- Origin Binding。 浏览器把当前页面的真实 origin(协议+域+端口)塞进给认证器的 challenge。钓鱼站
examp1e.com跟真站example.comorigin 不同, 私钥根本算不出有效签名。 - 设备本地解锁。 私钥永远不离开设备, 调用时用 Touch ID / Face ID / PIN 解锁。攻击者就算偷到 token 也用不上。
- Challenge 一次性 + 服务端随机生成。 每次登录 challenge 都不同, 重放无效。
注册流程 (Attestation)
登录流程 (Assertion)
登录时服务端发一个新 challenge, 设备用注册时存的私钥对 challenge + Origin + 计数器签名, 服务端用注册时存的公钥验证签名。整个过程没有"输入密码"这一步。
// Go 实战 · 用 go-webauthn/webauthn 库 $ go get github.com/go-webauthn/webauthn/webauthn import "github.com/go-webauthn/webauthn/webauthn" // 1) 初始化 RP (Relying Party) var wa, _ = webauthn.New(&webauthn.Config{ RPDisplayName: "Open Platform", RPID: "example.com", // 必须与浏览器实际域匹配 RPOrigins: []string{"https://example.com"}, }) // 2) 注册第一步: 服务端生成 challenge, 返回给前端 func beginRegister(w http.ResponseWriter, r *http.Request) { user := currentUser(r) options, session, _ := wa.BeginRegistration(user) saveSession(user.ID, session) // 服务端临时存 session (含 challenge) json.NewEncoder(w).Encode(options) } // 3) 注册第二步: 前端把设备返回的 attestation 发回, 服务端校验 func finishRegister(w http.ResponseWriter, r *http.Request) { user := currentUser(r) session := loadSession(user.ID) credential, err := wa.FinishRegistration(user, session, r) if err != nil { http.Error(w, err.Error(), 400); return } saveCredential(user.ID, credential) // 存 pub + credentialID w.Write([]byte("registered")) } // 4) 登录两步类似 — BeginLogin / FinishLogin func beginLogin(w http.ResponseWriter, r *http.Request) { user := lookupUser(r.FormValue("email")) options, session, _ := wa.BeginLogin(user) saveSession(user.ID, session) json.NewEncoder(w).Encode(options) } func finishLogin(w http.ResponseWriter, r *http.Request) { user := lookupUser(r.URL.Query().Get("email")) session := loadSession(user.ID) _, err := wa.FinishLogin(user, session, r) if err != nil { http.Error(w, err.Error(), 401); return } // 验证成功 — 颁发 Session Cookie (Day 01 / Day 04 流程) issueSession(w, r, user) }
浏览器端 JS
// register.js · 让浏览器调起 Touch ID / Face ID const opts = await fetch('/webauthn/begin-register').then(r => r.json()) // challenge / userId 从 base64url 解码为 ArrayBuffer opts.publicKey.challenge = b64ToBuf(opts.publicKey.challenge) opts.publicKey.user.id = b64ToBuf(opts.publicKey.user.id) const cred = await navigator.credentials.create({ publicKey: opts.publicKey }) await fetch('/webauthn/finish-register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: cred.id, rawId: bufToB64(cred.rawId), type: cred.type, response: { attestationObject: bufToB64(cred.response.attestationObject), clientDataJSON: bufToB64(cred.response.clientDataJSON), }, }), })
Passkey vs 传统硬件密钥
| 属性 | 硬件密钥 (YubiKey) | Passkey (云同步) |
|---|---|---|
| 私钥存哪 | 硬件芯片 · 物理隔离 | 设备本地 · iCloud / Google 加密同步 |
| 多设备使用 | 每设备插一次 / 配多把 | 同账号设备自动可用 |
| 设备丢失恢复 | 必须有备用密钥 (买两把) | 登录另一设备的同账号即恢复 |
| 抗钓鱼 | ✓ (Origin 绑定) | ✓ (Origin 绑定) |
| 合规等级 | AAL3 (硬件级) | AAL2 (云同步降低保证) |
| 用户体验 | 插入 + 触摸 | Face ID / Touch ID, 1 秒完成 |
常见疑问
Q1 TOTP 用 SHA-1 哈希, Day 02 不是说 SHA-1 已破了吗? +
SHA-1 被破的是抗碰撞性(找两个不同输入得到同一哈希), 而 HOTP / TOTP 用的是 HMAC-SHA1, 依赖的是原像抗性——这一性质至今仍然成立。
更重要的是, TOTP 验证只比较"6 位数字" mod 10⁶, 即使攻击者能找到 SHA-1 碰撞, 也很难精确控制结果的最后 6 位数字, 而且每 30 秒就过期, 跑暴力的窗口极短。
不过 RFC 6238 也定义了 SHA-256 / SHA-512 变体——只是 Google Authenticator / 大多数 App 默认只识别 SHA-1, 强制升级会导致兼容问题。结论: HMAC-SHA1 的 TOTP 仍然安全, 沿用即可; 新设计的 OTP 协议(如 Apple OTP)选 SHA-256。
Q2 既然 SMS OTP 这么不安全, 为什么主流银行还在用? +
三个现实理由——但都是"在被改善"的现状, 不是"永远如此":
(1) 用户基数与教育成本。银行的用户群覆盖老年人 / 不会装 App 的人。让他们学 TOTP / Passkey 需要全网客服改造, 短期内 SMS 仍是"最低公分母"。
(2) 跨设备 / 跨平台兼容。SMS 不挑设备, 不需要装应用, 不需要 OS 支持; Passkey 需要相对新的浏览器 + OS, 银行不能假设用户都用 iOS 17。
(3) 监管惯性。某些金融法规仍把"SMS OTP"列为"双因素", 改变监管语言需要数年。
方向上的事实: Apple Pay / 国内主流移动支付都已经在用 Passkey / FIDO 取代 SMS; 银行 App 内部也逐渐转向"App 内推送审批"代替 SMS 短信。你做新系统, 不要再设计"以 SMS 为主因素", 把 SMS 降到"恢复手段"位置。
Q3 Passkey 跟 WebAuthn 是什么关系? 看到很多文章混用。 +
层级关系是这样:
FIDO2 = WebAuthn + CTAP2。前者是浏览器 ↔ 服务端的协议, 后者是浏览器 ↔ 设备的协议。两者合在一起就是无密码登录的完整体系。
WebAuthn 是 W3C 标准, 定义浏览器里 navigator.credentials.create / get 这套 API。任何 FIDO2 凭证都用这套 API 操作。
Passkey 是 2022 年苹果 / 谷歌 / 微软联合推出的用户友好术语, 本质是"可同步的 WebAuthn 凭证"——区别于传统不可同步的硬件密钥。Apple Keychain / Google Password Manager 把私钥同步到云端, 你换设备同账号登录就自动有。
简单说: WebAuthn 是协议, Passkey 是它的云同步形态, 是为大众用户起的好记名字。
Q4 Passkey 同步到 iCloud / Google, 那不就等于私钥被云端拿到了吗? 这不破坏了"私钥永不外传"的原则? +
云同步用的是端到端加密, Apple / Google 服务器看到的是密文, 解密密钥只在你的设备里(由 iCloud Keychain 的设备绑定 + 你的设备密码派生)。所以 Apple / Google 本身看不到你的 Passkey 私钥。
但确实降低了安全保证等级: 你的设备密码弱 / 多设备里有一台被植入恶意软件 / iCloud 账号被攻破——任何一处出问题, Passkey 都会泄露。这就是为什么 NIST 把 Passkey 评级为 AAL2 而不是 AAL3(硬件级)。
实务建议: 普通用户用 Passkey(因为它便利, 而且仍然远比密码安全); 高价值账号(企业管理员 / 加密货币持有者)用不可同步的硬件密钥(YubiKey / Titan Key), 物理保管。Apple 也支持把 Passkey 标记为"仅本设备", 不上传 iCloud。
Q5 给开放平台做"应用级 MFA"应该怎么设计? 是每次调 API 都验, 还是只登录验一次? +
分两层来想:
用户登录控制台: 标准 MFA, 登录时验, 之后走 Session(Day 01 / 04 的方案)。这部分跟普通 SaaS 一样。
三方应用调你的 OpenAPI: 这里没有"用户在场", 走 OAuth 2.0 + Access Token, MFA 在用户授权应用那一刻已经完成(用户登录时已经验过 MFA, 然后授权给应用)。API 调用本身不应该再要 MFA——否则三方应用根本没法在后台跑批处理。
Step-up 时机: 真正需要二次验证的是敏感操作——给三方应用授权"全部资料"权限、修改 webhook 回调地址、删除应用等。这时候要重新引导用户登录并验 MFA(典型实现: OIDC 的 acr_values=urn:mace:incommon:iap:silver)。Day 17-19 的 OIDC 阶段会展开。
结论: 登录时验一次, 敏感操作要求 step-up, API 调用本身不验——三层各管各, 不要混。
复盘问题
- 用一句话讲清三类因子各自的代表机制。"密码 + 安全问题"为什么不算 2FA?
- 写出 TOTP 的完整公式(包括 HOTP 部分), 解释为什么需要"动态截断"。
- 讲清 SIM Swap 攻击的完整链——攻击者第一步、第二步...到拿到验证码的关键节点。
- WebAuthn 抗钓鱼的三个机制是什么? 为什么 evilginx2 类工具无法绕过它?
- 动手用 Go 写 8 位数字的 TOTP 函数, 用 RFC 6238 附录 B 的测试向量(K="12345678901234567890"; T=59) 验证输出是 94287082。
今日检查清单
- 能讲清 MFA 三要素 + 各自的典型机制
- 能区分 2FA / MFA / Step-up / Adaptive / Passwordless 五个术语
- 列出哪些 MFA 抗钓鱼, 哪些会被实时钓鱼代理绕过
- 能手算或写代码算 HOTP / TOTP 数值, 验证与 RFC 6238 测试向量一致
- 用 pquerna/otp 跑通 TOTP 绑定 + 校验流程, 用真实 Authenticator App 扫码
- 能讲清 SIM Swap 攻击, 知道 NIST 800-63B 为什么把 SMS 列为 RESTRICTED
- 能讲清 Passkey = 可同步的 WebAuthn 凭证, FIDO2 = WebAuthn + CTAP2
- 用 go-webauthn 跑通 Passkey 注册 + 登录全流程
推荐阅读
RFC 4226 · HOTP
HOTP 的开山规范。重点看 §5 算法定义和附录 D 测试向量, 把"动态截断"的设计意图想清楚。
RFC 6238 · TOTP
HOTP 的时间扩展, 附录 B 的测试向量是 Day 05 复盘题的答案。RFC 本身写得比博客清楚得多。
W3C WebAuthn Level 3
WebAuthn 现行规范。读 §1 (Use Cases) + §5 (Web Authentication API) 即可, 别去啃完整的 600 页。
NIST SP 800-63B · Authenticator Assurance
美国 NIST 的身份认证指南, AAL 三级标准来源。§5 是各种认证器(密码 / OTP / 多设备 / 加密) 的安全等级判定, 工程师的"判官手册"。
Yubico Developer Docs · passkeys.dev
Yubico 出品是硬件密钥视角; passkeys.dev 由 Apple / Google / Microsoft 联合维护, 是 Passkey 的官方实战参考——例子代码 / UI 文案 / 各浏览器兼容都有。
Day 06 预告 · Phase 2 开篇
JWT 深入: JWS / JWE / JWK / JWA
Phase 1 完成 —— 你应该已经懂"如何安全地登录"。
Phase 2 进入 OAuth / OIDC 时代的事实载荷格式 —— JWT 全家桶。
Day 06 把 JWT 的整族规范(JWS 签名 / JWE 加密 / JWK 公钥 / JWA 算法)一次理清:
Header / Payload / Signature 三段结构, RS256 / HS256 / ES256 / EdDSA 五种主流算法,
iss / sub / aud / exp / nbf / jti 七个标准声明。
看完 Day 06, 你能解释为什么"放在 Cookie 里的 JWT"和"放在 Authorization 头里的 JWT"安全模型完全不同。