背景与价值WebAuthn提供抗钓鱼的公钥凭证登录。挑战响应与来源校验保障令牌不可被跨站伪造或重放。统一规范可信来源:`origin` 与 `rpId` 精确匹配受控域。挑战一次性:挑战 `challenge` 仅在短窗口内有效且一次性使用。凭证绑定:`credentialId` 与用户绑定并校验签名计数递增。核心实现挑战管理type Challenge = { id: string; userId: string; challenge: string; exp: number } const challStore = new Map<string, Challenge>() function genId(): string { return Math.random().toString(36).slice(2) } function now(): number { return Date.now() } function issueChallenge(userId: string, ttlMs = 60000): Challenge { const c = { id: genId(), userId, challenge: genId(), exp: now() + ttlMs } challStore.set(c.id, c) return c } function takeChallenge(id: string): Challenge | null { const c = challStore.get(id) if (!c) return null challStore.delete(id) return c } 来源与rpId校验const allowOrigins = new Set(['https://app.example.com']) const rpId = 'app.example.com' function originAllowed(o: string): boolean { try { const u = new URL(o); return allowOrigins.has(u.origin) } catch { return false } } 签名验证(简化)type Assertion = { id: string; clientDataJSON: ArrayBuffer; authenticatorData: ArrayBuffer; signature: ArrayBuffer; userId: string } type Reg = { credentialId: string; publicKeySpki: ArrayBuffer; signCount: number } const regByUser = new Map<string, Reg>() function b64urlToBuf(s: string): ArrayBuffer { const b = atob(s.replace(/-/g,'+').replace(/_/g,'/')); const u = new Uint8Array(b.length); for (let i=0;i<b.length;i++) u[i] = b.charCodeAt(i); return u.buffer } async function importKey(spki: ArrayBuffer): Promise<CryptoKey> { return crypto.subtle.importKey('spki', spki, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']) } function parseClientData(buf: ArrayBuffer): { type: string; origin: string; challenge: string } { const s = new TextDecoder().decode(buf) return JSON.parse(s) } async function verifyAssertion(a: Assertion, challengeId: string): Promise<boolean> { const c = takeChallenge(challengeId) if (!c || c.userId !== a.userId || now() > c.exp) return false const cd = parseClientData(a.clientDataJSON) if (cd.type !== 'webauthn.get') return false if (!originAllowed(cd.origin)) return false if (cd.challenge !== c.challenge) return false const reg = regByUser.get(a.userId) if (!reg || reg.credentialId !== a.id) return false const key = await importKey(reg.publicKeySpki) const data = new Uint8Array([...new Uint8Array(a.authenticatorData), ...new Uint8Array(await crypto.subtle.digest('SHA-256', a.clientDataJSON))]).buffer const ok = await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, a.signature, data) if (!ok) return false reg.signCount++ return true } 落地建议将挑战设置短期有效并一次性使用,防止重放。严格校验 `origin/rpId` 与 `credentialId` 绑定,记录签名计数变化。注册与登录流程统一审计并进行失败告警与风险评估。验证清单`origin/rpId` 是否命中白名单且精确匹配。`challenge` 是否一次性使用且在有效窗口内。`credentialId` 与用户绑定是否一致,签名是否成功。

发表评论 取消回复