Kerberos、LDAP、SAML、WebAuthn 与 RADIUS 并不是依次替代的五代登录协议。它们分别组织票据、目录操作、跨系统断言、公钥凭据和网络准入,可以在同一套系统内协作。比较时先问:谁验证凭据,谁作授权决定,哪个组件最终执行该决定?
| 机制 | 主要对象 | 可信依据 | 常见使用位置 |
| Kerberos | principal、票据、会话密钥 | KDC 与服务长期密钥 | 域内服务认证与 SSO |
| LDAP | 目录条目与连接认证状态 | 目录认证方式及 ACL | 账号、组与属性查询 |
| SAML 2.0 | 身份断言及浏览器 SSO | SP 信任的 IdP 元数据与签名密钥 | 企业联合登录 |
| WebAuthn | 按 RP 作用域使用的公钥凭据 | 注册公钥与认证事务 | 用户登录、再次认证 |
| RADIUS | 网络接入认证与策略属性 | NAS、认证服务器及具体认证方法 | Wi-Fi、VPN、交换机准入 |
密码是否出现只是比较维度之一。密钥如何注册、如何撤销,账号怎样恢复、已建立会话如何失效,同样决定系统行为。
Kerberos:票据不是能反复发送的完整登录证明
KDC 包括 AS 与 TGS 两种逻辑职责。客户端先通过 AS 交换取得 TGT,再以 TGT 向 TGS 请求目标服务的票据,最后向服务发送 AP 请求。票据通常可缓存到有效期内使用,不是每读取一次文件就重新访问 TGS。
| 对象 | 由谁保护或使用 | 为什么客户端需要另一份会话密钥 |
| TGT | 加密部分由 TGS 的长期密钥保护 | 客户端不能靠读取 TGT 取得秘密,需 AS 回复中的受保护会话密钥 |
| 服务票据 | 加密部分由目标服务的长期密钥保护 | 客户端用配套会话密钥构造 authenticator |
| authenticator | 用相应会话密钥保护 | 证明当前请求者持有该密钥,并提供新鲜性信息 |
基于密码的初始交换不发送原始密码,但会使用密码派生的长期密钥,并通常包含预认证。Kerberos 也支持 PKINIT 等公钥初始认证,不能把“所有登录都只派生一次密码密钥”写成协议定律。RFC 4120、PKINIT
服务解密票据后还要核对目标 principal、有效期、authenticator 中的身份与时间等,并防止重复使用同一认证器。时间戳只限制可接受窗口,窗口内仍可能重放,因此不能用“时间未过期”替代重放缓存或协议允许的其他重放防护。允许的时钟偏差由实现和策略控制,不是全世界统一固定五分钟。
AP-REP 可提供双向认证,证明服务也掌握会话密钥。基础验证可不在线访问 KDC,但具体部署可能还有授权数据验证和其他依赖。对称密钥能被服务本身使用,所以“能解密就从密码学上证明只有 KDC 能签发”也不准确。
缓存既提高可用性,也带来撤销延迟:KDC 暂时不可达时,已有有效服务票据可能继续使用,申请新票据或续期则受影响。服务长期密钥泄露会影响该服务的票据信任;TGS 密钥泄露的范围更大。票据认证完成后,服务仍需根据主体和授权数据判断是否能读取某个目录或执行某个动作。
还在允许时间内,为什么第二次要拒绝?
在同一时刻递送两次,第二次即使时间正确仍是重放。生成新的认证器后,主体仍为alice,却可以再次验证。把接收时刻拉远则触发另一项时间检查,两个拒绝理由应分开记录。
正在呈现知识画面
还在允许时间内,为什么第二次要拒绝?时间窗限制陈旧程度,重放记忆限制重复使用。不能把认证器ID的一次性要求套到用户principal上,阻止同用户再次登录。
{"id":"kerberos-replay-time","title":"还在允许时间内,为什么第二次要拒绝?","summary":"时间窗限制陈旧程度,重放记忆限制重复使用。不能把认证器ID的一次性要求套到用户principal上,阻止同用户再次登录。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"control\"><label for=\"time\" data-i18n=\"time\"></label><output for=\"time\" id=\"time-value\"></output><input id=\"time\" type=\"range\" min=\"0\" max=\"100\" value=\"20\" step=\"1\"></div><div class=\"actions\"><button id=\"send\" data-i18n=\"send\"></button><button id=\"fresh\" data-i18n=\"fresh\"></button><button id=\"reset\" data-i18n=\"reset\"></button></div><section class=\"panel\"><h3 data-i18n=\"state\"></h3><div id=\"state\"></div></section><div class=\"metrics\"><div><span data-i18n=\"result\"></span><strong id=\"result\"></strong></div></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n.panel{border:1px solid var(--rule);border-radius:9px;padding:12px;margin:14px 0;min-width:0}.panel h3{font-size:13px;margin:0 0 10px;color:var(--muted)}.source{font:13px/1.8 var(--viz-font-mono);white-space:pre-wrap;overflow-wrap:anywhere;margin:12px 0}.source mark{background:var(--accent-soft);color:var(--ink);border-bottom:2px solid var(--accent)}.source .fault{background:var(--second-soft);border-color:var(--second)}.diagram{display:block;width:100%;height:auto;max-height:320px;margin:14px 0;overflow:visible}.diagram text{fill:var(--ink);font:12px var(--viz-font-ui)}.diagram .edge{stroke:var(--muted);stroke-width:1.5;fill:none}.diagram .edge.on{stroke:var(--accent);stroke-width:3}.diagram .node{fill:var(--surface);stroke:var(--rule);stroke-width:1.5}.diagram .node.on{fill:var(--accent-soft);stroke:var(--accent)}.diagram .node.gold{fill:var(--second-soft);stroke:var(--second)}.log{font:12px/1.8 var(--viz-font-mono);white-space:pre-line;overflow-wrap:anywhere;min-height:7.2em}.table{display:grid;gap:5px;margin:14px 0}.table .tr{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:5px}.table .tr>*{padding:8px 5px;background:var(--surface);border-radius:4px;min-width:0;overflow-wrap:anywhere;font-size:12px}.table .tr .on{background:var(--accent-soft)}.table .tr .gold{background:var(--second-soft)}.choices button{min-height:44px}.metrics strong{min-height:1.6em}.code-line{display:block}.code-line.on{background:var(--accent-soft);border-left:2px solid var(--accent);padding-left:6px}.status-badge{font-size:12px;color:var(--accent)}.tile-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.tile-grid .cell{min-height:40px}.actions button{min-height:48px}.form-field{display:grid;gap:8px;margin:14px 0;font-size:13px}.form-field input{width:100%;font-family:var(--viz-font-ui)}.metrics.three{grid-template-columns:repeat(3,minmax(0,1fr))}.formula{min-height:3.2em}.presets{flex-wrap:wrap}.presets button{flex-basis:25%;min-height:44px}\n\n.budget{display:flex;height:28px;margin:16px 0;background:var(--surface);border-radius:5px;overflow:hidden}.budget i{display:block;background:var(--accent);transition:width .2s}.budget i.gold{background:var(--second)}.intervals{margin:16px 0}.interval{position:relative;height:36px;background:var(--surface);margin:6px 0;border-radius:4px;overflow:hidden}.interval i{position:absolute;height:100%;background:var(--accent-soft);border-left:2px solid var(--accent)}.interval i.gold{background:var(--second-soft);border-color:var(--second)}.interval span{position:relative;z-index:1;font:12px/36px var(--viz-font-ui);padding-left:7px}.table .tr>*{min-height:4.8em}.formula{white-space:pre-line;min-height:7.2em}.actions:has(>button:only-child){grid-template-columns:1fr}.metrics strong{min-height:3.2em}.rangegrid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:3px;margin:14px 0}.rangegrid .cell{min-height:30px;padding:3px 1px;font-size:10px}.message{min-height:7.2em;white-space:pre-line;font:13px/1.8 var(--viz-font-ui);overflow-wrap:anywhere}\n/* Allow CJK hanging punctuation without overflowing a metric's border box. */\n.metrics>div{padding-right:4px}.metrics span{line-break:anywhere}\n\n#state .tr{grid-template-columns:1fr 1fr}.tr>*{min-height:3.6em}.metrics strong{font-size:17px}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}const esc=s=>String(s).replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));const fmt=s=>'{'+[...s].sort().join(', ')+'}';function graph(id,nodes,edges){const el=$('#'+id);el.setAttribute('viewBox','0 0 360 200');el.innerHTML='<defs><marker id=\"arrow-'+id+'\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"5\" markerHeight=\"5\" orient=\"auto-start-reverse\"><path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"var(--muted)\"/></marker></defs>'+edges.map(e=>{const a=nodes[e[0]],b=nodes[e[1]],dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1;if(edges.some(r=>r[0]===e[1]&&r[1]===e[0])){const nx=-dy/d,ny=dx/d;return '<path class=\"edge '+(e[2]?'on':'')+'\" d=\"M '+(a.x+dx/d*22+nx*12)+' '+(a.y+dy/d*22+ny*12)+' Q '+((a.x+b.x)/2+nx*38)+' '+((a.y+b.y)/2+ny*38)+' '+(b.x-dx/d*25+nx*12)+' '+(b.y-dy/d*25+ny*12)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}return '<line class=\"edge '+(e[2]?'on':'')+'\" x1=\"'+(a.x+dx/d*25)+'\" y1=\"'+(a.y+dy/d*25)+'\" x2=\"'+(b.x-dx/d*28)+'\" y2=\"'+(b.y-dy/d*28)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}).join('')+nodes.map(n=>'<g><circle class=\"node '+(n.cls||'')+'\" cx=\"'+n.x+'\" cy=\"'+n.y+'\" r=\"25\"/><text x=\"'+n.x+'\" y=\"'+(n.y+4)+'\" text-anchor=\"middle\">'+esc(n.name)+'</text>'+(n.sub?'<text x=\"'+n.x+'\" y=\"'+(n.y+43)+'\" text-anchor=\"middle\">'+esc(n.sub)+'</text>':'')+'</g>').join('');}function table(id,rows){$('#'+id).classList.add('table');$('#'+id).replaceChildren(...rows.map(r=>{const row=document.createElement('div');row.className='tr';r.forEach(x=>{const c=document.createElement('div');if(typeof x==='object'){c.textContent=x.text;c.className=x.cls||'';}else c.textContent=x;row.append(c)});return row;}));}function band(id,parts,total){$('#'+id).replaceChildren(...parts.map((p,i)=>{const e=document.createElement('i');e.style.width=(100*p.value/total)+'%';e.className=i%2?'gold':'on';e.title=p.name+': '+p.value;return e}));}function intervals(id,items,total){$('#'+id).replaceChildren(...items.map(p=>{const row=document.createElement('div');row.className='interval';const bar=document.createElement('i');bar.style.left=(100*p.start/total)+'%';bar.style.width=(100*(p.end-p.start)/total)+'%';bar.className=p.cls||'';const label=document.createElement('span');label.textContent=p.label;row.append(bar,label);return row;}));}let id=1,stamp=20,seen=new Set,last='∅';function draw(){set('time-value',$('#time').value);table('state',[['principal','alice'],['authenticator','A'+id+' @'+stamp],['already seen',seen.has(id)?'yes':'no']]);set('result',last);$('#state').dataset.snapshot=JSON.stringify({id,stamp,seen:[...seen],last});}$('#send').onclick=()=>{const valid=Math.abs(+$('input').value-stamp)<=30;if(!valid)last='REJECT: time';else if(seen.has(id))last='REJECT: replay';else{seen.add(id);last='ACCEPT'}draw()};$('#fresh').onclick=()=>{id++;stamp=+$('input').value;draw()};$('#reset').onclick=()=>{id=1;stamp=20;seen=new Set;last='∅';draw()};$('#time').oninput=draw;draw();","audio":false,"strings":{"fresh":"同主体生成新认证器","heading":"还在允许时间内,为什么第二次要拒绝?","intro":"改变接收时刻,重复递送同一认证器或发出同主体的新认证器。","reset":"重置","result":"上次验证结果","scope":"通用Kerberos认证器重放教学模型,允许±30s,票据/认证器加密及身份检查已通过。ID代表可唯一辨认的认证器元组,真实缓存键不只是新增线上ID字段。","scopeLabel":"模型范围与约定","send":"递送当前认证器","state":"认证器与重放状态","time":"接收时刻(s)"}}
LDAP:目录查询与连接身份是两层状态
LDAP 条目有 DN,如 uid=alice,ou=people,dc=example,dc=com;search 指定 base DN、scope、filter 和属性集合。DN 是目录位置标识,可以随重命名或移动而改变;目录账户的长期关联要依据部署所提供的稳定标识设计。RFC 4511
常见 search-then-bind 流程是:应用以有限权限服务身份搜索用户,确认恰好匹配一条记录,然后使用返回的 DN 和用户密码进行认证。直接把用户输入拼成 DN 不一定符合目录结构,搜索得到多条结果也不能任意取第一条。
| Bind 方式 | 状态含义 | 应注意的边界 |
| 空 DN、空密码 simple bind | 匿名状态 | 不代表某个用户认证成功 |
| 非空 DN、空密码 simple bind | 若服务器允许,可建立未认证的匿名状态 | 不能把成功结果当成该 DN 的密码验证 |
| 非空 DN、非空密码 simple bind | 用户名/密码认证 | 依赖正确验证的 TLS 等传输保护 |
| SASL bind | 由所选 SASL 机制决定 | SASL 是框架,不等于每种机制都不传密码或自动更强 |
登录界面应拒绝空密码进入用户名/密码认证路径。使用 StartTLS 时,要确认升级和服务端证书验证完成才提交凭据;不能把升级失败后继续 bind 当作等价路径。EXTERNAL 可利用外部已建立的安全身份,但证书身份到目录授权身份仍要映射。RFC 4513
bind 会改变连接的认证状态。在共享搜索连接上临时绑定成某个用户,会影响后续请求;认证连接和服务查询连接应有清晰生命周期。重新 bind 失败也不能假定原服务账号权限仍保留。
Filter 转义与 DN 转义不是同一规则
(uid=alice*) 中的星号有模式含义,若用户名真的包含星号,需要按 filter 语法编码。下面将 UTF-8 值中的每个字节转义,形式较冗长,但便于直接检查不会注入 filter 结构;实际应用使用 LDAP 库的 filter 值编码函数。
def filter_value(text):
return ''.join('\\' + format(b, '02x') for b in text.encode('utf-8'))
assert filter_value('a*(b)\\') == r'\61\2a\28\62\29\5c'
assert filter_value('中') == r'\e4\b8\ad'
assert filter_value('\0') == r'\00'
query = '(uid=' + filter_value('alice*') + ')'
assert query == r'(uid=\61\6c\69\63\65\2a)'
print('Filter 值按字节编码通过;不用于 DN 或属性名')
此函数只处理属性值,不能拿来允许用户任意选择搜索 base、属性名或过滤器操作符。RFC 4515
SAML:采信的是哪个签名元素、哪一次请求
在常见 SP 发起的 Web SSO 中,SP 保存请求 ID,经浏览器将 AuthnRequest 送到 IdP;IdP 完成适用的用户认证后,通常通过浏览器 POST 将 Response 送至 SP 的 ACS。SP 验证响应和断言,再创建自己的 session。浏览器携带的 Base64 XML 不是天然可信内容,也不意味着已经加密。SAML Profiles
| 检查 | 所回答的问题 |
| 可信 IdP 与签名密钥 | 是否来自预先建立信任的签发者 |
| 签名引用的元素 | 程序读取的身份属性是否正是已验证元素中的数据 |
| Audience | 断言是否适用于本 SP |
| Recipient / Destination | 响应或主体确认是否指向预期接收位置 |
| InResponseTo | 是否关联尚未完成的 SP 请求,适用于相应请求响应模式 |
| NotBefore / NotOnOrAfter | 是否处于允许使用的时间范围 |
| Assertion / Response ID 与缓存 | 此次可接受消息是否已经被使用 |
XML Signature Wrapping 的核心问题是“验签检查 A,业务却读取 B”。仅在文档中找到一个有效签名还不够;应使用成熟 SAML 库,并确保被消费的断言与签名验证结果是同一个结构对象。对元素 ID、解析和允许的签名结构也要按库和 profile 的要求处理。
NameID 是主体标识,不是要一次性消费的登录随机数。 它的格式和 issuer 等限定共同影响身份映射;持久 NameID 可以用于多次登录。防重放应关联请求 ID、断言/响应 ID 和有效期,不能把某个用户第一次登录后永久标成“NameID 已使用”。
IdP 发起的非请求响应登录没有同样的原始 SP 请求可匹配,需要显式采用该 profile 的处理政策;不能为了兼容就把所有回调的 InResponseTo 检查关掉。RelayState 常用于恢复应用状态,也不能直接成为任意站外跳转地址。SAML Core
下面只演示已经完成验签和其他条件验证后的一次性消费,实际服务须在共享存储中原子检查并登记,并保留到相应接受窗口结束。
seen = set()
def consume(issuer, assertion_id, expires, now):
key = (issuer, assertion_id)
if now >= expires or key in seen:
return False
seen.add(key)
return True
assert consume('idp-a', 'assertion-1', 200, 100)
assert not consume('idp-a', 'assertion-1', 200, 101)
assert consume('idp-a', 'assertion-2', 200, 101) assert not consume('idp-a', 'assertion-3', 200, 200)
print('断言重复与到期边界通过;NameID 不参与一次性消耗')
WebAuthn:凭据作用域、字节签名与用户验证
注册时,RP 发出 challenge,浏览器和认证器完成 navigator.credentials.create(),RP 验证注册结果并将 credential ID、公钥、用户句柄及所需状态关联保存。认证时使用 navigator.credentials.get(),验证器必须知道该凭据属于哪个本地账户;新增凭据本身也需要可靠的注册授权。
认证签名的输入是 authenticatorData || SHA256(clientDataJSON) 的原始字节。clientDataJSON 中包含 ceremony 类型、challenge、origin 等;authenticatorData 包含 RP ID 哈希、flags 与计数器等。不能把原始 JSON 解析后重新序列化再计算摘要,因为空白和字段顺序的变化会改变字节。
RP 应核对预期 challenge、允许的 origin、RP ID 哈希、credential 与账户关系,以及策略要求的 UP/UV 标志,再按凭据公钥和算法验证签名。UP 表示用户在场交互,UV 表示完成本地用户验证;触摸一下设备不总是等同于指纹或 PIN 验证。WebAuthn 验证过程
import hashlib
import json
import struct
rp_id = 'example.com'
raw_client_data = json.dumps({
'type': 'webauthn.get', 'challenge': 'example-only',
'origin': 'https://login.example.com'
}, separators=(',', ':')).encode()
auth_data = hashlib.sha256(rp_id.encode()).digest() + bytes([0x05])
auth_data += struct.pack('>I', 0)
signed_bytes = auth_data + hashlib.sha256(raw_client_data).digest()
assert len(auth_data) == 37 and len(signed_bytes) == 69
assert auth_data[32] & 0x01 and auth_data[32] & 0x04 assert hashlib.sha256(raw_client_data).digest() != hashlib.sha256(
json.dumps(json.loads(raw_client_data), indent=2).encode()).digest()
print('签名输入 69 B;此处检查编码,不代替真实验签与事务验证')
RP ID 通常是有效域名,origin 则包含协议、主机和适用的端口。login.example.com 在符合规则时可使用 example.com 作为 RP ID,但 RP 仍须限定允许的 origin;不能把二者直接字符串相等作为唯一检查。
Passkey 不等于“永远困在一台硬件里”
Passkey 可以是同步凭据,也可以绑定于单个设备。同步提供方在其保护机制下将凭据用于多个设备,与设备绑定密钥的生命周期不同;WebAuthn 不规定所有私钥必须位于不可导出的硬件安全单元。生物识别也不是必需条件,用户验证可以通过设备 PIN 等方式完成,RP 通常不接收原始生物特征。FIDO Passkeys
| 概念 | 作用 | 不应推导出的结论 |
| Discoverable credential | 认证器可发现账户凭据,支持不先输入用户名 | 所有凭据都必须云同步 |
| Attestation | 按格式和可信链提供认证器相关证据 | 自动证明某个自然人的真实身份 |
| Backup eligibility / state | 表示备份资格和当前备份状态 | 等于某一个具体云服务品牌 |
| signCount | 提供可能的复制或乱序信号 | 0 必然非法,或每次不增长都必定是攻击 |
没有实现计数器的认证器可以一直返回 0;其他情况下出现不增长,也可能涉及复制、故障或并发响应乱序,应按规范与风险策略处理。challenge 的一次性使用仍是事务新鲜性的核心之一。
域名作用域让仿冒站点不能像收集密码或 OTP 那样取得用于真实 RP 的认证结果。但账户恢复、已经登录的 session 和终端环境仍有自己的信任边界;移除密码并不自动让薄弱恢复流程变强。
签名正确,为什么还是不能登录?
初始RP ID是example.com,origin却是其登录子域,二者并不要求直接字符串相等。取消UV但保留UP,说明触摸在场不等于完成本策略要求的用户验证;其他字段正确也不能补偿。
正在呈现知识画面
签名正确,为什么还是不能登录?签名保护这些字节,没有替服务端决定它们是否符合当前事务。RP ID可以不同于origin主机的完整字符串,但两者仍分别核对。
{"id":"webauthn-scope-and-transaction","title":"签名正确,为什么还是不能登录?","summary":"签名保护这些字节,没有替服务端决定它们是否符合当前事务。RP ID可以不同于origin主机的完整字符串,但两者仍分别核对。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><label class=\"form-field\"><span data-i18n=\"rp\"></span><input type=\"text\" id=\"rp\" value=\"example.com\" maxlength=\"40\" autocomplete=\"off\" spellcheck=\"false\"></label><label class=\"form-field\"><span data-i18n=\"origin\"></span><input type=\"text\" id=\"origin\" value=\"https://login.example.com\" maxlength=\"50\" autocomplete=\"off\" spellcheck=\"false\"></label><label class=\"form-field\"><span data-i18n=\"challenge\"></span><input type=\"text\" id=\"challenge\" value=\"C42\" maxlength=\"12\" autocomplete=\"off\" spellcheck=\"false\"></label><label class=\"check\"><input type=\"checkbox\" id=\"up\" checked><span data-i18n=\"up\"></span></label><label class=\"check\"><input type=\"checkbox\" id=\"uv\" checked><span data-i18n=\"uv\"></span></label><div class=\"track-row\"><span data-i18n=\"checks\"></span><div id=\"checks\" class=\"track\"></div></div><div class=\"metrics\"><div><span data-i18n=\"result\"></span><strong id=\"result\"></strong></div></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n.panel{border:1px solid var(--rule);border-radius:9px;padding:12px;margin:14px 0;min-width:0}.panel h3{font-size:13px;margin:0 0 10px;color:var(--muted)}.source{font:13px/1.8 var(--viz-font-mono);white-space:pre-wrap;overflow-wrap:anywhere;margin:12px 0}.source mark{background:var(--accent-soft);color:var(--ink);border-bottom:2px solid var(--accent)}.source .fault{background:var(--second-soft);border-color:var(--second)}.diagram{display:block;width:100%;height:auto;max-height:320px;margin:14px 0;overflow:visible}.diagram text{fill:var(--ink);font:12px var(--viz-font-ui)}.diagram .edge{stroke:var(--muted);stroke-width:1.5;fill:none}.diagram .edge.on{stroke:var(--accent);stroke-width:3}.diagram .node{fill:var(--surface);stroke:var(--rule);stroke-width:1.5}.diagram .node.on{fill:var(--accent-soft);stroke:var(--accent)}.diagram .node.gold{fill:var(--second-soft);stroke:var(--second)}.log{font:12px/1.8 var(--viz-font-mono);white-space:pre-line;overflow-wrap:anywhere;min-height:7.2em}.table{display:grid;gap:5px;margin:14px 0}.table .tr{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:5px}.table .tr>*{padding:8px 5px;background:var(--surface);border-radius:4px;min-width:0;overflow-wrap:anywhere;font-size:12px}.table .tr .on{background:var(--accent-soft)}.table .tr .gold{background:var(--second-soft)}.choices button{min-height:44px}.metrics strong{min-height:1.6em}.code-line{display:block}.code-line.on{background:var(--accent-soft);border-left:2px solid var(--accent);padding-left:6px}.status-badge{font-size:12px;color:var(--accent)}.tile-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.tile-grid .cell{min-height:40px}.actions button{min-height:48px}.form-field{display:grid;gap:8px;margin:14px 0;font-size:13px}.form-field input{width:100%;font-family:var(--viz-font-ui)}.metrics.three{grid-template-columns:repeat(3,minmax(0,1fr))}.formula{min-height:3.2em}.presets{flex-wrap:wrap}.presets button{flex-basis:25%;min-height:44px}\n\n.budget{display:flex;height:28px;margin:16px 0;background:var(--surface);border-radius:5px;overflow:hidden}.budget i{display:block;background:var(--accent);transition:width .2s}.budget i.gold{background:var(--second)}.intervals{margin:16px 0}.interval{position:relative;height:36px;background:var(--surface);margin:6px 0;border-radius:4px;overflow:hidden}.interval i{position:absolute;height:100%;background:var(--accent-soft);border-left:2px solid var(--accent)}.interval i.gold{background:var(--second-soft);border-color:var(--second)}.interval span{position:relative;z-index:1;font:12px/36px var(--viz-font-ui);padding-left:7px}.table .tr>*{min-height:4.8em}.formula{white-space:pre-line;min-height:7.2em}.actions:has(>button:only-child){grid-template-columns:1fr}.metrics strong{min-height:3.2em}.rangegrid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:3px;margin:14px 0}.rangegrid .cell{min-height:30px;padding:3px 1px;font-size:10px}.message{min-height:7.2em;white-space:pre-line;font:13px/1.8 var(--viz-font-ui);overflow-wrap:anywhere}\n/* Allow CJK hanging punctuation without overflowing a metric's border box. */\n.metrics>div{padding-right:4px}.metrics span{line-break:anywhere}\n\n.metrics strong{font-size:18px}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}const esc=s=>String(s).replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));const fmt=s=>'{'+[...s].sort().join(', ')+'}';function graph(id,nodes,edges){const el=$('#'+id);el.setAttribute('viewBox','0 0 360 200');el.innerHTML='<defs><marker id=\"arrow-'+id+'\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"5\" markerHeight=\"5\" orient=\"auto-start-reverse\"><path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"var(--muted)\"/></marker></defs>'+edges.map(e=>{const a=nodes[e[0]],b=nodes[e[1]],dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1;if(edges.some(r=>r[0]===e[1]&&r[1]===e[0])){const nx=-dy/d,ny=dx/d;return '<path class=\"edge '+(e[2]?'on':'')+'\" d=\"M '+(a.x+dx/d*22+nx*12)+' '+(a.y+dy/d*22+ny*12)+' Q '+((a.x+b.x)/2+nx*38)+' '+((a.y+b.y)/2+ny*38)+' '+(b.x-dx/d*25+nx*12)+' '+(b.y-dy/d*25+ny*12)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}return '<line class=\"edge '+(e[2]?'on':'')+'\" x1=\"'+(a.x+dx/d*25)+'\" y1=\"'+(a.y+dy/d*25)+'\" x2=\"'+(b.x-dx/d*28)+'\" y2=\"'+(b.y-dy/d*28)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}).join('')+nodes.map(n=>'<g><circle class=\"node '+(n.cls||'')+'\" cx=\"'+n.x+'\" cy=\"'+n.y+'\" r=\"25\"/><text x=\"'+n.x+'\" y=\"'+(n.y+4)+'\" text-anchor=\"middle\">'+esc(n.name)+'</text>'+(n.sub?'<text x=\"'+n.x+'\" y=\"'+(n.y+43)+'\" text-anchor=\"middle\">'+esc(n.sub)+'</text>':'')+'</g>').join('');}function table(id,rows){$('#'+id).classList.add('table');$('#'+id).replaceChildren(...rows.map(r=>{const row=document.createElement('div');row.className='tr';r.forEach(x=>{const c=document.createElement('div');if(typeof x==='object'){c.textContent=x.text;c.className=x.cls||'';}else c.textContent=x;row.append(c)});return row;}));}function band(id,parts,total){$('#'+id).replaceChildren(...parts.map((p,i)=>{const e=document.createElement('i');e.style.width=(100*p.value/total)+'%';e.className=i%2?'gold':'on';e.title=p.name+': '+p.value;return e}));}function intervals(id,items,total){$('#'+id).replaceChildren(...items.map(p=>{const row=document.createElement('div');row.className='interval';const bar=document.createElement('i');bar.style.left=(100*p.start/total)+'%';bar.style.width=(100*(p.end-p.start)/total)+'%';bar.className=p.cls||'';const label=document.createElement('span');label.textContent=p.label;row.append(bar,label);return row;}));}function draw(){const checks={RP:$('#rp').value==='example.com',origin:$('#origin').value==='https://login.example.com',challenge:$('#challenge').value==='C42',UP:$('#up').checked,UV:$('#uv').checked};cells('checks',Object.entries(checks).map(([text,ok])=>({text,cls:ok?'on':'error'})));set('result',Object.values(checks).every(Boolean)?'ACCEPT':'REJECT');$('#result').dataset.checks=JSON.stringify(checks);}document.querySelectorAll('input').forEach(e=>e.oninput=draw);draw();","audio":false,"strings":{"challenge":"clientDataJSON.challenge","checks":"分别验证","heading":"签名正确,为什么还是不能登录?","intro":"分别改变RP ID、origin、challenge与UP/UV,观察认证器数据和客户端数据的两组约束。","origin":"clientDataJSON.origin","reset":"重置","result":"认证结果","rp":"authenticatorData 的 RP ID","scope":"凭据签名与账户绑定已验证;期望RP ID example.com、origin https://login.example.com、challenge C42、要求UP和UV。开关只模拟服务端条件,不触发真实认证器。","scopeLabel":"模型范围与约定","up":"UP:用户在场","uv":"UV:用户验证"}}
RADIUS 与 EAP:认证服务器决定,NAS 执行
典型 802.1X 中,终端通过 EAPOL 与 AP/交换机交互,NAS 把 EAP 消息装进 RADIUS 属性送往认证服务器。EAP 是可扩展认证框架,EAP-TLS 才是其中使用 TLS 的方法;不能把所有 EAP 都叫加密隧道。RADIUS/EAP、EAP-TLS 1.3
RADIUS Access-Request 可得到 Access-Challenge、Access-Accept 或 Access-Reject。多轮 challenge 使用 State 等属性关联认证过程;Accept 可能包含 VLAN、过滤或会话时限等授权属性。认证通过但 VLAN 没有正确落地,应该核对 NAS 的执行与属性支持,而不是只重新输入密码。
经典 RADIUS/UDP 常用 1812 认证、1813 计费,基础头部 20 B,包括 Code、Identifier、Length 和 16 B Authenticator;Authenticator 的具体构造取决于消息类型,不能统一称为“随机数”或“全包加密”。共享秘密与传统属性保护不等于整个报文有现代传输机密性。RFC 2865
RADIUS/TLS 或 DTLS 保护 NAS/代理与服务器之间的传输,和终端到 EAP 服务器的 EAP-TLS 是两个边界。RFC 9765 定义的 RADIUS/1.1 是实验性规范,通过 TLS/DTLS 上的 ALPN 协商移除其模式中的共享秘密、MD5 包认证和属性混淆机制;不能宣称所有既有 UDP RADIUS 已因此升级。RADIUS/1.1
RADIUS 服务器可以使用本地配置、证书、目录或其他身份源,并非必须把账号数据放在 LDAP/AD。计费记录描述会话事件和统计,也不天然构成精确一次的账本,应用需处理重发、缺失和会话关联。
TACACS+ 常用于设备管理中的认证、逐命令授权和计费。其传统协议头明文、body 采用基于共享秘密的混淆,不能简化成“全程现代加密”;是否适合替代某个 RADIUS 部署取决于设备功能和管理需求。RFC 8907
将它们组合成身份系统
一个企业可以用 LDAP 保存目录,以 Kerberos 访问域服务,让 IdP 通过 WebAuthn 认证用户,再向 Web 应用签发 SAML 或 OIDC 结果;办公网络则由 RADIUS/EAP 做接入。这些组件共享人员生命周期,却不自动共享同一份会话状态。
员工离职后,目录停用、票据剩余有效期、IdP 会话、应用 session、已登记 passkey 和网络接入会话会分别变化。完整设计应明确每类状态的持有者、有效期与撤销传播方式。协议选型之后,仍需把这条身份生命周期串起来。