webAuthn
# 简介
基于密码的身份验证的主要弱点之一是密码是共享机密,密码是证明你就是你的唯一钥匙。
对于用户和开发人员来说,密码相关的问题越来越多:
- 用户必须担心密码被网络钓鱼工具窃取,一旦泄漏,别人就可以冒充自己。
- 如果他们拥有帐户的网站遭到入侵,他们的密码会在网上泄露。
- 还得在没有专用密码管理工具的情况下创建和记住各种密码,很容易就忘记密码。
- 开发人员必须担心通过系统传递密码并将其安全地存储在数据库中的所有复杂性。
所以,W3C
和 FIDO
联合 Google、Mozilla、Microsoft、Yubico
等编写了 Web Authentication API
的规范,即我们常说的 WebAuthn
。
WebAuthn
是 FIDO2
框架的一部分,该框架是一组技术,可在服务器、浏览器和身份验证器之间实现无密码身份验证。
自 2019 年 1 月起,Chrome、Firefox 和 Edge 以及 Safari 支持 WebAuthn
。
允许服务器与现在内置于设备中的强大身份验证器集成 (例如 Windows Hello
或 Apple's Touch ID
) 代替密码,为网站创建了一个 私有-公共密钥对
(称为凭证)。
私钥安全地存储在用户的设备上,公钥和随机生成的 凭证 ID
被发送到服务器进行存储。然后服务器就可以使用该公钥来证明用户的身份。
注:公钥不是私密的,因为没有对应的私钥它实际上是无用的。
# 注册
在基于密码的用户注册流程中,服务器通常会向用户提供一个表单,要求用户输入用户名和密码。密码将被发送到服务器进行存储。
在 WebAuthn
中,服务器必须提供将用户绑定到 凭证(私钥-公钥对)
的数据,包括用户和组织(也称为 relying party
)的标识符。
需要注意的是,我们需要从 服务器
随机生成的字符串 作为 挑战值(challenge),以防止 replay attacks
。
Registering a New Credential (opens new window)
# 注册新凭证
服务器将通过在客户端上调用 navigator.credentials.create()
开始创建新凭证:
const credential = await navigator.credentials.create({
publicKey: publicKeyCredentialCreationOptions
});
2
3
publicKeyCredentialCreationOptions
对象包含许多必需和可选字段,由 服务器
指定这些字段为用户创建新凭证。
const publicKeyCredentialCreationOptions = {
challenge: Uint8Array.from("y9NZaMnmBZw2EOQZDv7ivNQoDT0W5ynNaLKKL3OC3UQ", c => c.charCodeAt(0)), // Random String From Server
rp: {
name: "FIDO Examples Corporation",
// id: "localhost", // 默认值即可
},
user: {
id: Uint8Array.from("UZSL85T9AFC", c => c.charCodeAt(0)),
name: "runs@logyi.com",
displayName: "Runs",
},
pubKeyCredParams: [{alg: -7, type: "public-key"}],
authenticatorSelection: {
authenticatorAttachment: "cross-platform",
residentKey: "discouraged",
requireResidentKey: false,
userVerification: "required"
},
timeout: 60000,
attestation: "direct"
};
const credential = await navigator.credentials.create({
publicKey: publicKeyCredentialCreationOptions
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
challenge
: 挑战值是服务器上生成的加密随机字节的缓冲区,是防止 replay attacks
所必需的。具体看规范 (opens new window)
rp
: 这代表依赖方 relying party
,它可以被认为是描述 负责注册和验证用户的组织
。具体查看规范 (opens new window)
rpID
必须是源的有效域,或源有效域的可注册域后缀。例如 https://fido.example.com:1337
,有效的 rpID
为 fido.example.com
(默认值) 和 example.com
。具体查看规范 (opens new window)
user
: 当前注册用户的信息。 具体查看规范 (opens new window)
pubKeyCredParams
: 这是一组对象,描述了服务器可接受的公钥类型。 alg
是 COSE 注册表
中描述的数字,例如,-7 表示服务器接受使用 SHA-256 签名算法的椭圆曲线公钥
。具体查看规范 (opens new window)
authenticatorSelection
: 此 可选对象
有助于 依赖方
进一步限制允许注册的身份验证器类型。具体看规范 (opens new window)
authenticatorAttachment
: 身份验证器类型,cross-platform
表示漫游认证器,比如:Yubikey
这样通过 'usb', 'nfc', 'ble' 跨平台通讯的认证器,而不是Windows Hello
或Apple Touch ID
这样的platform
身份验证器。residentKey
: 指定依赖方希望创建 客户端可发现凭证(client-side credential storage modality) 的程度。具体查看规范 (opens new window)当设置时,使用该值来决定 requireResidentKey 为 true 还是 false
具体查看规范 (opens new window)requireResidentKey
: 当 residentKey 没有设置时,使用该值决定 requireResidentKey 的值, 默认为 false.userVerification
: 是否需要用户认证,比如输入 PIN, 指纹等, 通过之后才可进行对认证器请求注册新凭证navigator.credentials.create()
。默认为 preferred
timeout
: 指定 依赖方
愿意等待注册调用完成的响应时间(以毫秒为单位)。具体查看规范 (opens new window)
如果 authenticatorSelection.userVerification
设置为:
discouraged
:- 推荐范围:30000 毫秒到 180000 毫秒。
- 推荐的默认值:120000 毫秒(2 分钟)。
required
或preferred
:- 推荐范围:30000 毫秒到 600000 毫秒。
- 推荐的默认值:300000 毫秒(5 分钟)。
attestation
: 从身份验证器返回的证明数据,包含可用于跟踪用户的信息 (opens new window)。
none
表示服务器不关心证明indirect
表示服务器将允许匿名证明数据direct
表示服务器希望接收来自认证者的认证数据。
navigator.credentials.create() 返回的 credential 是一个包含公钥和其他用于验证注册事件属性的对象。
console.log(credential);
PublicKeyCredential {
id: "8c5LLqC1XoTWA3CQwSf0OcVzU1NcoZhDpM_jr8cW8TXhQAtXIESgOtvELKVLfFEB635htCBd5lDbtbuNSTOlMQ",
rawId: ArrayBuffer(64),
response: AuthenticatorAttestationResponse {
attestationObject: ArrayBuffer(1023),
clientDataJSON: ArrayBuffer(262),
},
type: 'public-key'
}
2
3
4
5
6
7
8
9
10
11
id
: 新生成的 Credential ID
,对用户进行身份验证时的识别凭据。格式为 base64 编码
的字符串。具体查看规范 (opens new window)
rawId
: 跟上面的 id
一样是 Credential ID
,格式为二进制的 ArrayBuffer
注:这两个
id
都是Credential ID
,等于attestationObject
中的authenticatorData
里面的CredID
。
登陆进行身份认证时,我们会把这个id
发给认证器,认证器通过这个id
解析出这个用户所用公钥对的私钥,然后使用该私钥对数据进行加密并发送给服务器,服务器使用对应的公钥对数据进行解密,从而实现对用户的认证。
attestationObject
: 格式为 CBOR
编码的二进制数据,包含 authenticator data
和 attestation statement
。具体查看规范 (opens new window)
clientDataJSON
: 格式为 UTF-8
字节数组,从浏览器传递到身份验证器的数据,用于将 新凭证(Credential ID) 与 服务器 和 浏览器 相关联。具体查看规范 (opens new window)
# 解析和验证注册数据
获得 PublicKeyCredential
后,将其发送到服务器进行验证。
注:在成功收到
PublicKeyCredential
之前, 需要做相关的数据校验,这一部分在navigator.credentials.create
API 中校验,具体的校验过程看规范 (opens new window)
解析验证 clientDataJSON
// converting the UTF-8 byte array into a JSON-parsable string.
// 将 UTF-8 字节数组转换为 JSON 可解析字符串
const utf8Decoder = new TextDecoder('utf-8');
const decodedClientData = utf8Decoder.decode(credential.response.clientDataJSON)
const clientDataObj = JSON.parse(decodedClientData);
console.log(clientDataObj)
// {
// type: "webauthn.create",
// challenge: "u7GBZa9-LcruvE1Ga4aKoJLYMg9aqjfI-yOcbuqftMQ", // base64url
// origin: "http://localhost:3000",
// crossOrigin: false
// }
2
3
4
5
6
7
8
9
10
11
12
13
14
type
: 服务器验证该值是否为webauthn.create
字符串,如果提供了其他字符串,则表明身份验证器执行了不正确的操作challenge
: 服务器必须验证该挑战值是否与注册时传递给navigator.credentials.create()
参数对象(即上面publicKeyCredentialCreationOptions
)中的challenge
相同origin
: 服务器必须验证该值是否与服务器的 origin 相同crossOrigin
: 是否跨域
解析验证 attestationObject
// note: a CBOR decoder library is needed here.
// cbor-js: https://github.com/paroga/cbor-js
// cbor-x: https://github.com/kriszyp/cbor-x
const decodedAttestationObj = CBOR.decode(credential.response.attestationObject);
console.log(decodedAttestationObject);
// {
// fmt: "packed",
// authData: Uint8Array(196),
// attStmt: {
// alg: -7,
// sig: Uint8Array(70),
// x5c: Array(1) [Uint8Array(705)]
// }
// }
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fmt
: 认证格式,表明服务器应该如何解析和验证该数据。具体查看webauthn-3
第八章节 Defined Attestation Statement Formats (opens new window)authData
: 身份认证器数据(authenticator data),包含有关注册事件的元数据,以及用于后面登录验证的公钥。具体查看规范 (opens new window)attStmt
: 认证声明,根据上面的fmt
认证格式的不同,会有不同的数据声明。服务器使用签名数据sig
和 证书x5c
来验证收到的公钥是否来自身份认证器。具体查看规范 (opens new window)
Attestation (opens new window)
const { authData } = decodedAttestationObject;
// get the length of the credential ID
const dataView = new DataView(new ArrayBuffer(2));
const idLenBytes = authData.slice(53, 55);
idLenBytes.forEach((value, index) => dataView.setUint8(index, value));
const credentialIdLength = dataView.getUint16();
// get the credential ID
const credentialId = authData.slice(55, 55 + credentialIdLength);
// get the public key object
const publicKeyBytes = authData.slice(55 + credentialIdLength);
// the publicKeyBytes are encoded again as CBOR
const publicKeyObject = CBOR.decode(publicKeyBytes.buffer);
console.log(publicKeyObject);
// {
// 1: 2, // 密钥类型为椭圆曲线格式
// 3: -7, // ES256 签名算法
// -1: 1, // P-256 曲线
// -2: Uint8Array(32) // 公钥的 x 坐标
// -3: Uint8Array(32) // 公钥的 y 坐标
// }
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
publicKeyObject
: 解析出来的公钥是一个以 COSE
(opens new window) 标准编码的对象。
1
: 密钥类型(kty),2 表示 密钥类型为椭圆曲线格式3
: 签名算法(alg),-7 表示 ES256 签名算法-1
: 曲线类型(crv),1 表示 P-256 曲线-2
: 公钥的 x 坐标-3
: 公钥的 y 坐标- 规范 (opens new window)
根据 fmt
的值,执行相应的验证过程,具体查看规范 (opens new window)。
比如 fmt
为 packed
的 Verification procedure (opens new window)。
如果上面验证过程都成功,则服务器需要将
公钥
(对应上面的 publicKeyBytes ) 和credentialId
等数据与用户相关联,并存储到数据库中。
具体的操作请查看规范
WebAuthn Relying Party Operations
章节中的Registering a New Credential
(opens new window)
# 登录 / 身份验证
登陆进行身份验证时,服务器生成随机挑战值,并将用户对应的 credentialId
一起发送给客户端,客户端再发给认证器,
认证器通过这个 credentialId
解析出这个用户所用公钥对的私钥,然后使用该私钥对数据进行加密并发送给服务器,
服务器使用对应的公钥对数据进行解密,从而实现对用户的认证。
Verifying an Authentication Assertion (opens new window)
# 获取凭证
在客户端上调用 navigator.credentials.get()
向认证器获取凭证,发送给服务器,从而证明他们拥有对应的私钥。
const credential = await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions
});
2
3
publicKeyCredentialCreationOptions
对象包含许多必需和可选字段,服务器指定这些字段为用户创建相应的凭证。
const publicKeyCredentialRequestOptions = {
challenge: Uint8Array.from(randomStringFromServer, c => c.charCodeAt(0)),
allowCredentials: [{
id: Uint8Array.from(credentialId, c => c.charCodeAt(0)),
type: 'public-key',
transports: ['usb', 'ble', 'nfc'],
}],
timeout: 60000,
}
const assertion = await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions
});
2
3
4
5
6
7
8
9
10
11
12
13
challenge
: 与注册期间一样,这必须是在服务器上生成的随机字节。
allowCredentials
: 表示服务器希望使用哪些凭据进行身份认证。具体查看规范 (opens new window)
timeout
: 与注册期间一样,指定 依赖方
愿意等待的响应时间(以毫秒为单位)
console.log(assertion);
PublicKeyCredential {
id: 'ADSUllKQmbqdGtpu4sjseh4cg2TxSvrbcHDTBsv4NSSX9...',
rawId: ArrayBuffer(59),
response: AuthenticatorAssertionResponse {
authenticatorData: ArrayBuffer(191),
clientDataJSON: ArrayBuffer(118),
signature: ArrayBuffer(70),
userHandle: ArrayBuffer(10),
},
type: 'public-key'
}
2
3
4
5
6
7
8
9
10
11
12
13
id
rawId
: 同一个数据的不同格式,表示生成身份验证断言的凭据标识符。(上面 allowCredentials
可以有多个凭据,这里返回认证器所使用的凭据对应的 id)
authenticatorData
: 类似于注册期间收到的 authData
,不一样的是这里不包含公钥,是在身份验证期间用作生成签名的数据之一
clientDataJSON
: 与注册期间一样,是从浏览器传递到身份验证器的数据的集合,是身份验证期间用作生成签名的数据之一
signature
: 与此凭证关联的私钥生成的签名。服务器使用对应的公钥验证此签名是否有效。具体查看规范 (opens new window)
userHandle
: 该字段由身份验证器可选提供,表示 user.id
在注册期间提供的字段。它可用于将此断言与服务器上的用户相关联,格式为 UTF-8
字节数组。具体查看规范 (opens new window)
# 解析和验证身份验证数据
获得断言后,将其发送到服务器进行验证。
- 服务器检索与用户关联的公钥对象
- 服务器使用公钥来验证签名,该签名是使用 authenticatorData 字节 和 SHA-256 哈希生成的 clientDataJSON
具体的操作请查看规范
WebAuthn Relying Party Operations
章节中的Verifying an Authentication Assertion
(opens new window)
# 其他
每个验证器都有一个
AAGUID
,它是一个128
位的标识符,指示验证器的类型(例如,品牌和型号)。
制造商选择的 AAGUID 在该制造商制造的所有基本相同的认证器中必须是相同的,并且与所有其他类型的认证器的 AAGUID 不同。
注意:仅当与
HTTPS
一起使用 或在localhost
主机名上使用 WebAuthn API 时才起作用(在这种情况下,不需要HTTPS)。
# 参考
- https://github.com/fido-alliance/webauthn-demo (opens new window)
- https://slides.com/fidoalliance/jan-2018-fido-seminar-webauthn-tutorial/fullscreen (opens new window)
- https://webauthn.guide (opens new window)
- https://w3c.github.io/webauthn (opens new window)
- https://developer.mozilla.org/zh-CN/docs/Web/API/Web_Authentication_API (opens new window)