程序员 关于 WebAuthn API

deepzz · 2022年04月18日 · 156 次阅读

很久很久以前,在一个遥远的星球上…

网络钓鱼!?

被 WebAuthn/FIDO2 彻底消灭啦!

那么…

什么是 FIDO2 和 WebAuthn 呢?本文将带大家一起讨论,了解浏览器中的凭证管理,FIDO2 和 WebAuthn,以及如何消灭密码,带领大家进入一个防网络钓鱼的无密码认证世界。

什么是 FIDO?

FIDO,即 FIDO 联盟,致力于开发安全、开放、标准、防网络钓鱼的身份认证协议。该联盟始创于 2013 年,目前成员数已达 300 多位,遍布全球。FIDO 制定的 UAF、U2F 以及 FIDO2 三项协议均属于同一系列,都是基于来源、挑战—响应、防网络钓鱼以及数字签名的身份认证协议。

FIDO 工作原理

下图解释了 FIDO 协议的整个工作过程:

依赖方(即服务器)向客户端(即浏览器)发送挑战和先前注册的凭证标识符。客户端附加上调用来源等依赖方信息后,发送到认证器。

认证器首先会要求用户按下按钮来检查用户是否在场,或使用设备 PIN 码、生物识别等进行全面的用户验证。

身份验证完成后,设备使用由凭证 ID 标识的私钥对有效负载进行签名,并将断言返回给客户端。然后,客户端附加上之前发送给认证器的信息,作为签名负载的一部分,并发送给依赖方。

依赖方对这些信息进行检查,确保 RP 信息中包含应有的来源与挑战信息,然后再检查签名。如果过程中有任一步骤出现问题,我们就可以检测到网络钓鱼攻击,并采取防御措施。

更多关于 FIDO 协议的深度讨论,点这里

FIDO2 还是 WebAuthn?

FIDO 联盟发布的协议中出现了很多术语,例如 FIDO、FIDO2、WebAuthn、CTAP1、CTAP2 等等,很多用户都对此不甚了解。下面我们就逐个解释一下这些术语的含义。

  • FIDO:即 Fast IDentity Online(线上快速身份验证)的缩写,或称 FIDO 联盟。该联盟主要致力于构建安全、开放、防网络钓鱼的无密码身份验证标准。FIDO 系列协议是由 FIDO 联盟制定的一套协议,包括通用认证框架 UAF(Universal Authentication Framework)、通用第二因素认证标准 U2F(Universal 2nd Factor)以及 FIDO2。当说到使用“FIDO”时,通常是指使用这三种协议中的任何一种,因为从概念的角度来看,这三者基本一致,只是结构有所区别(UAF——TLV,U2F——RAW,FIDO2——CBOR)。
  • FIDO2:即满足现代化、简便性、安全性、防网络钓鱼、无密码身份验证的一个新协议。其核心规范包括 WebAuthn(客户端 API)以及 CTAP(认证器 API)。
  • CTAP:即客户端到认证器协议(Client to Authenticator Protocols),是与基于 BLE/NFC/USB 的认证器进行通信的一系列底层协议,包括 CTAP1 和 CTAP2。
  • U2F:即通用认证框架,正式名称为 CTAP1。
  • CTAP2:即 CTAP 协议的第二个版本,主要特征包括编码结构使用 CBOR 格式、向后兼容 U2F(CTAP1)、扩展以及新的证明格式等。CTAP1 和 CTAP2 的传输层均相同,所以两者的差别主要体现在结构上。
  • 平台:基本是指操作系统,包括 Android、iOS、Windows、MacOS、Linux 等等。无论是系统 API 还是浏览器,都以平台作为 FIDO 客户端 API 的基础。
  • 认证器:即使用 FIDO 协议验证用户身份的软硬件。FIDO2 包含两种认证器,一个是安全密钥,还有一个是平台认证器。
  • 安全密钥:即通过 USB、NFC 或 BLE 连接的硬件设备,例如 Yubikey、Feitian 以及 TrustKey 等硬件产品。
  • 平台认证器:即内置于平台(操作系统)的认证器。截至目前,几乎所有的 iOS、Android、MacOS 以及 Windows 设备均支持 FIDO。
  • WebAuthn API:即描述用于创建和管理公钥凭证接口的浏览器 JS API。

凭证管理 API

WebAuthn 实际上是凭证管理 API 的一个扩展。所以,在我们深入探讨无密码认证之前,首先来了解一下凭证管理 API。

简单来说,凭证管理 API 是一个 JS“自动填充插件”。之前通常用 UI 预测工具完成的工作,如“保存密码”,现在都可以用 JS 实现。例如,我们可以使用包含 PasswordCredential 对象的 navigator.credentials.store 添加新凭证:

var passwordcred = new PasswordCredential({
    'type': 'password',
    'id': 'alice',
    'password': 'VeryRandomPassword123456'
})

navigator.credentials.store(passwordcred)

注意:请确保 Chrome 已启用“建议保存密码”)

调用发起后,你会看到一个提示,让你确认是否保存新凭证(即用户名 + 密码):

如果想要找回的话,我们可以调用navigator.credentials.get,“password”设置为“true”:

navigator.credentials.get({ 'password': true })
   .then((credential) => {
       if(!credential)
           throw new Error('No credential found')

       // sendServer(credential) // PasswordCredential {iconURL: "", name: "", password: "VeryRandomPassword123456", id: "alice", type: "password"}
   })

如果有多个凭证,用户将收到提示进行选择:

所以,基本的方法包括“store”和“get”,当然还有“create”,将在下一部分讨论。

更多关于凭证管理 API 的信息,点这里

Web Authentication API

WebAuthn 是一个管理公钥凭证的 API。简单来说,就是与 FIDO 认证器对话的接口,向其发送挑战后,会返回断言。

在这中间需要了解两项操作,分别是MakeCredentialGetAssertion

var challenge = new Uint8Array(32);
window.crypto.getRandomValues(challenge);

var userID = 'Kosv9fPtkDoh4Oz7Yq/pVgWHS8HhdlCto5cR0aBoVMw='
var id = Uint8Array.from(window.atob(userID), c=>c.charCodeAt(0))

var publicKey = {
    'challenge': challenge,

    'rp': {
        'name': 'Example Inc.'
    },

    'user': {
        'id': id,
        'name': 'alice@example.com',
        'displayName': 'Alice Liddell'
    },

    'pubKeyCredParams': [
        { 'type': 'public-key', 'alg': -7  },
        { 'type': 'public-key', 'alg': -257 }
    ]
}

navigator.credentials.create({ 'publicKey': publicKey })
    .then((newCredentialInfo) => {
        console.log('SUCCESS', newCredentialInfo)
    })
    .catch((error) => {
        console.log('FAIL', error)
    })

我们来看一下上面这个例子:

  • challenge:服务器发送的一个随机挑战,用于缓解 MITM 攻击。
  • rp:依赖方信息,rp.name 是唯一的必填字段,包含依赖方的友好名称。rp.icon 包含认证器上显示依赖方图标的链接。rp.id 包含依赖方的标识符,具体将在后面的应用方案部分讨论。
  • user:用户信息,id、name 以及 displayName 为必填字段。
  • user.id:服务器生成的用户标识符,不允许包含任何用户信息,且必须随机生成。
  • user.name:用户名,可以包含电子邮件、用户名、电话号码等任何依赖方认为可以用作主要用户标识符的信息。
  • user.displayName:用户的实际全名,例如张三。
  • pubKeyCredParams:服务器支持的签名算法列表,目前 FIDO2 服务器需支持 RS1、RS256、ES256 以及 ED25519。

上述为创建凭证的基本参数,还包括一些可选键值:

  • timeout:超时。
  • excludeCredentials:包含一个用户已注册的凭证列表。这个列表会发送到认证器,如果认证器识别到列表中的任何一个凭证,认证器就会以错误 CREDENTIAL_EXISTS 取消操作,从而防止同一认证器重复注册。
  • authenticatorSelection:具体说明认证器的选择偏好。
  • authenticatorSelection.authenticatorAttachment:规定所需的认证器类型。“platform”表示只需要内置认证器,“cross-platform”表示只需要外部、漫游的安全密钥。
  • authenticatorSelection.requireResidentKey/authenticatorSelection.residentKey: 强制创建可发现凭证的选项,详见应用方案部分的“可发现凭证”。

  • authenticatorSelection.userVerification:明确了用户身份验证的要求。设置为“required”,浏览器会通过 ClientPin、生物识别或其他可用方法执行用户验证。如果没有用户验证选项可用,则失败;设置为“preferred”,浏览器会尝试进行用户验证,如果没有选项可用,将默认为 TUP(用户在场测试,即触摸按钮);设置为“discouraged”则只进行用户在场测试。
  • attestation:证明响应选项。“direct”表示需要完全证明;“none”表示不关心,为默认设置;“indirect”表示希望客户端对证明进行匿名处理,例如使用 Attestation CA,但据笔者所知,目前还没有设置过这个选项。
  • extensions:包含一个特定扩展的映射。

publicKey”结构需要封装进字典,作为“publicKey”的键值,然后就可以调用navigator.credentials.create

上述代码可以在这里自行尝试。

调用 WebAuthn API 时,浏览器(至少 Chrome 是这样)还会跳出弹框,让用户选择认证器:

如果用户拥有安全密钥,安全密钥就会作为其中一个选项,否则将使用浏览器/平台内置的认证器。点击选项后,轻触认证器,或使用指纹完成身份验证,你会看到弹窗中显示“成功”。

打开控制台,WebAuthn API 已返回 PublicKeyCredential 对象。

  • id/rawId:凭证标识符。依赖方通过在 allowList 中提交凭证标识符来识别设备上的凭证,并使用 disallowList 排除认证器的意外重新注册。rawId 是一个字节数组,而 id 是 base64url 编码的 rawId。
  • type:标准的 WebAuthn API 类型,向依赖方表明使用了“public-key”凭证。目前基本都是“public-key”类型,未来可能会出现其他凭证类型。
  • type/id/rawId:对于创建凭证和获取断言都是一样的。
  • response:认证器结果。创建凭证和获取断言有各自不同的结构。
  • response.attestationObject:CBOR 编码的证明结构。
  • response.clientDataJSON:浏览器会话信息,包含 base64url 编码的挑战,调用来源(如https://webauthnworks.github.iocreate还是get)以及跨源调用的相关信息。),调用类型(

依赖方检查挑战是否设置为预期值,从而防止 MITM 攻击。来源字段应设置为预期来源,否则可能造成网络钓鱼攻击。ClientDataJSON 通过加密保护,clientDataHash 在签名前附加到 authData。

上述结构未来可能会发生变化,这也是为什么依赖方必须正确解码结构并验证字段,而不能使用任何形式的基于模版的验证。

AttestationObject

结果证明是一个包含三个字段的 CBOR 字典:fmt 表示证明格式,authData 表示新生成的凭证,attStmt 表示设备证明。

在上面的例子中,由于我们没有要求证明,所以证明格式为“none”。在这种情况下,平台会把 attStmt 设置为空映射,fmt 设置为 none,authData 中的设备 aaguid 为 00000000–0000–0000–0000–000000000000。因为 attestation 存在一些固有的隐私问题,这样设置能够保护用户的隐私。更多详细讨论,点这里。

AuthData 是一个字节数组,包含调用方主机的串联哈希(如 webauthnworks.github.io 的 SHA256 哈希值)、计数器、标志(告知依赖方是否进行了用户身份验证)、设备 GUID、新的凭证 ID(同 result.id/rawId)以及新生成的公钥。更多关于验证结果的相关内容,点这里。

关于证明(Attestation)

证明是一种 FIDO 协议机制,能够让依赖方识别和证明设备模型。这种证明机制对于银行和政府等依赖方来说非常有用,但大多数依赖方都不需要也不应该使用证明,可以把证明字段设置为 none,或不对其进行定义。

如果明确需要证明,可以在调用中将证明字段设置为“direct”,然后会提示用户同意(Popup)。

var challenge = new Uint8Array(32);
window.crypto.getRandomValues(challenge);

var userID = 'Kosv9fPtkDoh4Oz7Yq/pVgWHS8HhdlCto5cR0aBoVMw='
var id = Uint8Array.from(window.atob(userID), c=>c.charCodeAt(0))

var publicKey = {
    'challenge': challenge,

    'rp': {
        'name': 'Example Inc.'
    },

    'user': {
        'id': id,
        'name': 'alice@example.com',
        'displayName': 'Alice Liddell'
    },

    'pubKeyCredParams': [
        { 'type': 'public-key', 'alg': -7  },
        { 'type': 'public-key', 'alg': -257 }
    ],

    'attestation': 'direct'
}

navigator.credentials.create({ 'publicKey': publicKey })
    .then((newCredentialInfo) => {
        console.log('SUCCESS', newCredentialInfo)
    })
    .catch((error) => {
        console.log('FAIL', error)
    })

上述代码可以在这里自行测试。

GetAssertion

用户添加认证器后,就可使用 FIDO2 身份认证来登录账户。通过 navigator.credentials.get 实现。

var publicKey = {
    challenge: challenge,

    allowCredentials: [
        { type: "public-key", id: credentialId }
    ]
}

navigator.credentials.get({ 'publicKey': publicKey })
  .then((getAssertionResponse) => {
      alert('SUCCESSFULLY GOT AN ASSERTION! Open your browser console!')
      console.log('SUCCESSFULLY GOT AN ASSERTION!', getAssertionResponse)
  })
  .catch((error) => {
      alert('Open your browser console!')
      console.log('FAIL', error)
  })
  • challenge:和navigator.credentials.create中的挑战相同。
  • allowCredentials:用户账户注册的凭证标识符列表,该列表将发送给所有可用的设备。如果认证器识别到列表中的凭证,就会提示用户操作(如按钮或指纹)开始断言生成过程。如果认证器无法识别到凭证,就会返回错误,从而通知平台此设备有误。第一个成功的设备结果将完成获取断言的请求。

对于可发现凭证,并不强制使用 allowList,稍后我们会进行讨论。

一旦断言返回到浏览器,ClientDataJSON 就会将整个结构返回给用户:

上述代码可以在这里自行尝试。

GetAssertion 结果中的字段与 MakeCredential 有所不同:

  • authenticatorData:与 attestationObject 中的 authData 相同。
  • signature:使用用户的私钥对 authenticatorData 和 clientDataHash 串联签名,并由用户的公钥进行验证。
  • userHandle:创建凭证时设置的 user.id。由于 U2F 不支持 user handle,所以 U2F 凭证中的 userHandle 字段为空。部分依赖方希望将凭证与他们自己的索引注册表相关联,那么 userHandle 字段就能发挥很大作用。

此结构将返回到服务器进行验证,具体的验证过程可以查看文章“WebAuthn/FIDO2: Verifying responses”。

应用方案

在这个部分,我们将讨论一下各种不同的 WebAuthn 身份验证应用方案。

“我就是个猫咪博客,只需要安全的 2FA 就行了”

这个是最简单的应用场景。只要用户使用他的用户名和密码登录,就开始了 2FA 流程。

document.getElementById('register').addEventListener('submit', function(event) {
    event.preventDefault();

    let username    = this.username.value;
    let password    = this.password.value;
    let displayName = this.displayName.value;

    registerPassword({username, password, displayName})
        .then((serverResponse) => {
            if(serverResponse.status !== 'startFIDOEnrollment')
                throw new Error('Error registering user! Server returned: ' + serverResponse.errorMessage);

            return getMakeCredentialChallenge()
        })
        .then((makeCredChallenge) => {
            /*{
                "challenge": "YPpAQ5-8yw7ty1GxvZRoosKoYraXWpeNJ4jNffh-gy0",
                "rp": {
                    "name": "Example Inc."
                },
                "user": {
                    "id": "pH4atM-uM2FlifiEVD5OtZnSrvxMcS1OXao8fEP6UFs",
                    "name": "alice@example.com",
                    "displayName": "Alice von Delingher"
                },
                "pubKeyCredParams": [
                    { "type": "public-key", "alg": -7 },
                    { "type": "public-key", "alg": -257 }
                ],
                "status": "ok"
            }*/
            makeCredChallenge = preformatMakeCredReq(makeCredChallenge);
            return navigator.credentials.create({ 'publicKey': makeCredChallenge })
        })
        .then((newCredentialInfo) => {
            newCredentialInfo = publicKeyCredentialToJSON(newCredentialInfo)
            return makeCredentialResponse(newCredentialInfo)
        })
        .then((serverResponse) => {
            if(serverResponse.status !== 'ok')
                throw new Error('Error registering user! Server returned: ' + serverResponse.errorMessage);

            alert('Success!');
        })
        .catch((error) => {
            alert('FAIL' + error)
            console.log('FAIL', error)
        })
})

在这个例子中:

首先,进行用户注册,注册成功后,调用“getMakeCredentialChalleng”来获取创建凭证挑战。

这些字段都为 base64url 编码,由于无法使用 JSON 发送缓冲区,所以必须通过使用 preformatMakeCreReq 预处理请求来解码 base64url 编码的字段。

接着,调用格式化请求的 WebAuthn API,运用 publicKeyCredToJSON 将结果证明解码到 JSON 并发送到服务器,服务器对其进行验证。

document.getElementById('login').addEventListener('submit', function(event) {
    event.preventDefault();
    let username    = this.username.value;
    let password    = this.password.value;
    loginPassword({username, password})
        .then((serverResponse) => {
            if(serverResponse.status !== 'startFIDOAuthentication')
                throw new Error('Error logging in! Server returned: ' + serverResponse.errorMessage);
            return getGetAssertionChallenge()
        })
        .then((getAssertionChallenge) => {
            /* Server GetAssertion sample */
            /*{
                "challenge": "Ld0vp5byLeFZBOpclgKP3BEc8AA4aBewYPlwbkgLh98",
                "allowCredentials": [
                    {
                        "type": "public-key",
                        "id": "SIT9gAgwUyzOLB_F9fA_LwMOu--dcXHSlzvEXipg2QP3-Shr5f-nldK5V1Wc9BdiMDkkSpK0uPmsLb-CYigbog"
                    }
                ],
                "status": "ok"
            }*/
            getAssertionChallenge = preformatGetAssertReq(getAssertionChallenge);
            return navigator.credentials.get({ 'publicKey': getAssertionChallenge })
        })
        .then((newCredentialInfo) => {
            newCredentialInfo = publicKeyCredentialToJSON(newCredentialInfo)
            return getAssertionResponse(newCredentialInfo)
        })
        .then((serverResponse) => {
            if(serverResponse.status !== 'ok')
                throw new Error('Error registering user! Server returned: ' + serverResponse.errorMessage);
            alert('Success!');
        })
        .catch((error) => {
            alert('FAIL' + error)
            console.log('FAIL', error)
        })
})

登录的流程与注册基本相同,使用之前注册的用户名和密码进行登录。

客户端获取一个登录挑战,使用 preformatGetAssertReq 对其解码,然后传递到 WebAuthn API。

响应经过编码后会发送到服务器,如果服务器成功验证,则返回状态:ok。

上述代码可以在这里自行尝试。

“我是一家银行,需要证明用户的认证器”

这个应用场景和前面那个完全相同,只不过添加了“证明”键并设置为“direct”,要求客户端返回完整证明:

document.getElementById('register').addEventListener('submit', function(event) {
    event.preventDefault();

    let username    = this.username.value;
    let password    = this.password.value;
    let displayName = this.displayName.value;

    registerPassword({username, password, displayName})
        .then((serverResponse) => {
            if(serverResponse.status !== 'startFIDOEnrollment')
                throw new Error('Error registering user! Server returned: ' + serverResponse.errorMessage);

            return getMakeCredentialChallenge({'attestation': 'direct'})
        })
        .then((makeCredChallenge) => {
            /*{
                "challenge": "YPpAQ5-8yw7ty1GxvZRoosKoYraXWpeNJ4jNffh-gy0",
                "rp": {
                    "name": "Example Inc."
                },
                "user": {
                    "id": "pH4atM-uM2FlifiEVD5OtZnSrvxMcS1OXao8fEP6UFs",
                    "name": "alice@example.com",
                    "displayName": "Alice von Delingher"
                },
                "pubKeyCredParams": [
                    { "type": "public-key", "alg": -7 },
                    { "type": "public-key", "alg": -257 }
                ],

                // SETTING ATTESTATION DIRECT
                "attestation": "direct",


                "status": "ok"
            }*/
            makeCredChallenge = preformatMakeCredReq(makeCredChallenge);
            return navigator.credentials.create({ 'publicKey': makeCredChallenge })
        })
        .then((newCredentialInfo) => {
            newCredentialInfo = publicKeyCredentialToJSON(newCredentialInfo)
            return makeCredentialResponse(newCredentialInfo)
        })
        .then((serverResponse) => {
            if(serverResponse.status !== 'ok')
                throw new Error('Error registering user! Server returned: ' + serverResponse.errorMessage);

            alert('Success!');
        })
        .catch((error) => {
            alert('FAIL' + error)
            console.log('FAIL', error)
        })
})

上述代码可以在这里行尝试。

“我要消灭密码!”

部分读者可能对“无密码”这个词无法认同,因为现在很多公司大谈特谈“无密码”,过度滥用,一旦听到说密码在某些情况下比生物识别优势更大,就会条件反射似的反驳。因此,在展开讨论“无密码认证”之前,我们先来了解一下无密码的含义?

这三个术语需要加以理解和认识:

  • 无密码:即不使用密码
  • 身份认证:即向第三份证明自己
  • 用户验证:解锁你的认证器的过程

“密码认证”中没有认证器,所以身份认证和验证是等同的,依赖方单独完成这两项工作,验证了你的密码,也就验证了你的身份。

但在“无密码认证”中,这是两个不同的流程。依赖方只能看到认证器生成的断言,而用户验证则由认证器通过生物特征识别、PIN 等用户验证方式完成。

这也说明了 FIDO2 能够进行无密码认证的原因,因为密码不需要在网络上传输。如果使用的是客户端 PIN,由于 PIN 码存储在设备上,所以暴力破解的可能性微乎其微,用户就算使用的是弱 PIN 码也不必担心凭证损害。而且,利用生物特征识别也使得整个验证过程更加友好便捷。

(之所以称之为“无密码认证”,是因为密码不会在网络上传输,从而也就没有密码可以被泄漏。密码/PIN 这些还是可以用在验证过程中的,和生物特征识别一样。)

回到无密码认证的实现。如果要使用无密码身份认证,你只要进行用户验证这一步。

在无密码认证的应用场景中,用户验证是至关重要的一步。倘若依赖方不执行用户验证,就无法知道用户声明是否真实。如此一来,身份认证就不再是多因素的,因为只剩下了用户设备证明这唯一一个因素。攻击者只需要用户名和安全密钥就能进入账户。

所以,为了避免上述攻击,用户在创建凭证时,我们在 makeCredential 调用中添加了“userVerification”并设置为“required”。

document.getElementById('register').addEventListener('submit', function(event) {
    event.preventDefault();

    let username    = this.username.value;
    let displayName = this.displayName.value;

    startPasswordlessEnrolment({username, displayName})
        .then((serverResponse) => {
            if(serverResponse.status !== 'startFIDOEnrolmentPasswordless')
                throw new Error('Error registering user! Server returned: ' + serverResponse.errorMessage);

            return getMakeCredentialChallenge({'uv': true})
        })
        .then((makeCredChallenge) => {
            /*{
                "challenge": "YPpAQ5-8yw7ty1GxvZRoosKoYraXWpeNJ4jNffh-gy0",
                "rp": {
                    "name": "Example Inc."
                },
                "user": {
                    "id": "pH4atM-uM2FlifiEVD5OtZnSrvxMcS1OXao8fEP6UFs",
                    "name": "alice@example.com",
                    "displayName": "Alice von Delingher"
                },
                "pubKeyCredParams": [
                    { "type": "public-key", "alg": -7 },
                    { "type": "public-key", "alg": -257 }
                ],
                "authenticatorSelection": {
                    "userVerification": "required"
                },
                "status": "ok"
            }*/
            makeCredChallenge = preformatMakeCredReq(makeCredChallenge);
            return navigator.credentials.create({ 'publicKey': makeCredChallenge })
        })
        .then((newCredentialInfo) => {
            newCredentialInfo = publicKeyCredentialToJSON(newCredentialInfo)

            return makeCredentialResponse(newCredentialInfo)
        })
        .then((serverResponse) => {
            if(serverResponse.status !== 'ok')
                throw new Error('Error registering user! Server returned: ' + serverResponse.errorMessage);

            alert('Success!');
        })
        .catch((error) => {
            alert('FAIL' + error)
            console.log('FAIL', error)
        })
})

在这段代码中,你首先会看到工作流中缺少了 password 字段,因此这是无密码认证。

其次,“userVerification”设置为“required”,要求认证器使用生物识别、客户端 PIN 等方法的其中一种进行用户验证。如果认证器不支持用户验证,则命令失败,返回错误。 依赖方收到响应后,解码 authData,检查用户验证 UV 标志为“true”。

document.getElementById('login').addEventListener('submit', function(event) {
    event.preventDefault();

    let username = this.username.value;

    startAuthenticationPasswordless({username})
        .then((serverResponse) => {
            if(serverResponse.status !== 'startFIDOAuthentication')
                throw new Error('Error logging in! Server returned: ' + serverResponse.errorMessage);

            return getGetAssertionChallenge()
        })
        .then((getAssertionChallenge) => {
            /*{
                "challenge": "Ld0vp5byLeFZBOpclgKP3BEc8AA4aBewYPlwbkgLh98",
                "allowCredentials": [
                    {
                        "type": "public-key",
                        "id": "SIT9gAgwUyzOLB_F9fA_LwMOu--dcXHSlzvEXipg2QP3-Shr5f-nldK5V1Wc9BdiMDkkSpK0uPmsLb-CYigbog"
                    }
                ],
                "userVerification": "required",
                "status": "ok"
            }*/
            getAssertionChallenge = preformatGetAssertReq(getAssertionChallenge);
            return navigator.credentials.get({ 'publicKey': getAssertionChallenge })
        })
        .then((newCredentialInfo) => {
            newCredentialInfo = publicKeyCredentialToJSON(newCredentialInfo)

            return getAssertionResponse(newCredentialInfo)
        })
        .then((serverResponse) => {
            if(serverResponse.status !== 'ok')
                throw new Error('Error registering user! Server returned: ' + serverResponse.errorMessage);

            alert('Success!');
        })
        .catch((error) => {
            alert('FAIL' + error)
            console.log('FAIL', error)
        })
})

在上面的代码中,只有用户名被发送。服务器生成了 GetAssertion 挑战,且“userVerification”设置为“required”。

那么,无密码认证的过程是什么样子呢?大家可以在这里一探究竟。

这就是让密码失效的方法。现在,下一个是谁?

“为什么只需要手机或者电脑就能实现认证呢?”——平台认证器

现如今,每台安卓、iOS、MacOS 和 Windows 设备都包含操作系统内置的认证器,我们称之为平台认证器。这意味着,任何网站都可以开始使用 FIDO 认证而无需购买安全密钥。

平台认证器是全系统范围的,所有的浏览器都能访问相同的密钥库,也就是说 Chrome 中注册凭证,在 Firefox 上也依然能够访问。

到 2021 年 1 月为止,MacOS 尚处于积极开发阶段,所以基于 Chromium 的浏览器的凭证和 UI/UX 体验会不同于 Safari,但之后都会改变。

平台支持

平台认证器可用于下列平台:

  • Windows 10+(自 2019 年更新起)
  • MacOS 10.15+
  • Android 8+
  • iOS 14+

检测

检测平台是否支持 FIDO 认证,可使用 PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable():

const isWebAuthnSupported = () => {
  return !!window.PublicKeyCredential
}

const isPlatformAuthenticatorSupported = () => {
  if (!isWebAuthnSupported()) {
    return Promise.reject(new Error("WebAuthn API is not available"))
  }

  if (!PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable) {
    return Promise.resolve(false)
  } else {
    return PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
  }
}

isWebAuthnSupported通过检查 PublickeyCredential 是否定义来检测是否支持 WebAuthn API。

isWebAuthnSupported检查 WebAuthn API 是否可用,如果支持isUserVerifyingPlatformAuthenticatorAvailable(),就会进行调用。

大部分浏览器都支持 WebAuthn API,但也存在部分例外,包括“Samsung Internet”三星浏览器以及许多小型浏览器。

使用平台认证器

在调用平台 API 时,默认情况下用户会收到提示,选择使用平台认证器或安全密钥进行注册。

如果只想让用户看见平台选项,则必须把authenticatorSelection.authenticatorAttachment设置为 platform。(注意:跨平台只适用于安全密钥)

document.getElementById('register').addEventListener('submit', function(event) {
    event.preventDefault();

    let username    = this.username.value;
    let displayName = this.displayName.value;

    startPasswordlessEnrolment({username, displayName})
        .then((serverResponse) => {
            if(serverResponse.status !== 'startFIDOEnrolmentPasswordless')
                throw new Error('Error registering user! Server returned: ' + serverResponse.errorMessage);

            return getMakeCredentialChallenge({'uv': true})
        })
        .then((makeCredChallenge) => {
            /*{
                "challenge": "YPpAQ5-8yw7ty1GxvZRoosKoYraXWpeNJ4jNffh-gy0",
                "rp": {
                    "name": "Example Inc."
                },
                "user": {
                    "id": "pH4atM-uM2FlifiEVD5OtZnSrvxMcS1OXao8fEP6UFs",
                    "name": "alice@example.com",
                    "displayName": "Alice von Delingher"
                },
                "pubKeyCredParams": [
                    { "type": "public-key", "alg": -7 },
                    { "type": "public-key", "alg": -257 }
                ],
                "authenticatorSelection": {
                    "authenticatorAttachment": "platform"
                },
                "status": "ok"
            }*/
            makeCredChallenge = preformatMakeCredReq(makeCredChallenge);
            return navigator.credentials.create({ 'publicKey': makeCredChallenge })
        })
        .then((newCredentialInfo) => {
            newCredentialInfo = publicKeyCredentialToJSON(newCredentialInfo)

            return makeCredentialResponse(newCredentialInfo)
        })
        .then((serverResponse) => {
            if(serverResponse.status !== 'ok')
                throw new Error('Error registering user! Server returned: ' + serverResponse.errorMessage);

            alert('Success!');
        })
        .catch((error) => {
            alert('FAIL' + error)
            console.log('FAIL', error)
        })
})

从依赖方的角度来看,证明响应不会产生变化,但是可能需要在数据库条目中添加一些标志或说明来表明这是一个平台凭证,利于分析和用户体验等等。

GetAssertion 过程其实也没有多少变化。依赖方希望指明凭证为平台,可以通过在凭证描述符 transports 字段值设置为“internal”来实现。

{
    "challenge": "Ld0vp5byLeFZBOpclgKP3BEc8AA4aBewYPlwbkgLh98",
    "allowCredentials": [
        {
            "type": "public-key",
            "id": "SIT9gAgwUyzOLB_F9fA_LwMOu--dcXHSlzvEXipg2QP3-Shr5f-nldK5V1Wc9BdiMDkkSpK0uPmsLb-CYigbog",
            "transports": ["internal"]
        }
    ],
    "userVerification": "required",
    "status": "ok"
}

“用户名也必须消灭”——关于可发现凭证

大家可能会觉得至少用户名应该在身份验证中保留,但实际情况并不是如此。上面提到的可发现凭证(Discoverable Credentials),过去称作常驻凭证(Resident Credentials),是一种特殊的凭证,能够在预先不知道所识别凭证的情况下获取。基本流程是用挑战进行调用,用户选择凭证,然后等待响应。

依赖方有两个重要标识符来对结果凭证进行绑定:id 和 userHandle。依赖方可以通过凭证 id 或 userHandle 来识别凭证,二者都是在注册凭证时依赖方设置的 user.id。

然后,依赖方就可以通过搜索具有响应 id 或 userHandle 的认证器来找到用户。

document.getElementById('register').addEventListener('submit', function(event) {
    event.preventDefault();

    let username    = this.username.value;
    let displayName = this.displayName.value;

    startPasswordlessEnrolment({username, displayName})
        .then((serverResponse) => {
            if(serverResponse.status !== 'startFIDOEnrolmentRK')
                throw new Error('Error registering user! Server returned: ' + serverResponse.errorMessage);

            return getMakeCredentialChallenge()
        })
        .then((makeCredChallenge) => {
            /*{
                "challenge": "YPpAQ5-8yw7ty1GxvZRoosKoYraXWpeNJ4jNffh-gy0",
                "rp": {
                    "name": "Example Inc."
                },
                "user": {
                    "id": "pH4atM-uM2FlifiEVD5OtZnSrvxMcS1OXao8fEP6UFs",
                    "name": "alice@example.com",
                    "displayName": "Alice von Delingher"
                },
                "pubKeyCredParams": [
                    { "type": "public-key", "alg": -7 },
                    { "type": "public-key", "alg": -257 }
                ],
                "authenticatorSelection": {
                    "requireResidentKey": true
                },
                "status": "ok"
            }*/
            makeCredChallenge = preformatMakeCredReq(makeCredChallenge);
            return navigator.credentials.create({ 'publicKey': makeCredChallenge })
        })
        .then((newCredentialInfo) => {
            newCredentialInfo = publicKeyCredentialToJSON(newCredentialInfo)

            return makeCredentialResponse(newCredentialInfo)
        })
        .then((serverResponse) => {
            if(serverResponse.status !== 'ok')
                throw new Error('Error registering user! Server returned: ' + serverResponse.errorMessage);

            alert('Success!');
        })
        .catch((error) => {
            alert('FAIL' + error)
            console.log('FAIL', error)
        })
})

MakeCredential 部分不需要变化太多,和上面的“无密码认证”方案中的相同。

创建可发现凭证,需要依赖方将“requireResidentKey”设置为“true”,从而强制平台仅使用支持可发现凭证的认证器。如果找不到认证器,则调用失败。

结果与“无用户名”方案相同。

document.getElementById('login').addEventListener('submit', function(event) {
    event.preventDefault();

    return getGetAssertionChallenge()
        .then((getAssertionChallenge) => {
            /*{
                "challenge": "Ld0vp5byLeFZBOpclgKP3BEc8AA4aBewYPlwbkgLh98",
                "status": "ok"
            }*/
            getAssertionChallenge = preformatGetAssertReq(getAssertionChallenge);
            return navigator.credentials.get({ 'publicKey': getAssertionChallenge })
        })
        .then((newCredentialInfo) => {
            newCredentialInfo = publicKeyCredentialToJSON(newCredentialInfo)

            return getAssertionResponse(newCredentialInfo)
        })
        .then((serverResponse) => {
            if(serverResponse.status !== 'ok')
                throw new Error('Error registering user! Server returned: ' + serverResponse.errorMessage);

            alert('Success!');
        })
        .catch((error) => {
            alert('FAIL' + error)
            console.log('FAIL', error)
        })
})

在身份认证过程中,由于我们将平台切换到了可发现凭证模式,所以依赖方只能发送一个挑战。

依赖方可以使用 id 或 userHandle 值找到用户。

现在用户名也没有了,一切只剩下唯一一个按钮来进行登录:

大家可以在这里自行尝试:

关于 residentKey 和 requireResidentKey

residentKey 字段是对 requireResidentKey 的替换。依赖方之后可以指定可发现凭证的偏好。例如,用户常驻凭证已设置为“preferred”但用户的设备并不支持,那么在这种情况下,平台就会竭力去创建可发现凭证,只不过同时也允许常规的、非可发现凭证的使用。依赖方也可以将可发现凭证设置为“required”。

从目前来看,依赖方在创建可发现凭证时,将此功能添加到 API 调用中,但是对 residentKey 的支持还需要一些时日。

可发现凭证的现状与未来

截止 2021 年 1 月,对可发现凭证的支持依然存在问题。Windows 目前已完全支持,但所有其他平台要么完全不支持,要么还处于早期阶段,原因主要有以下几点:

1、CTAP2.0 隐私问题:在 CTAP2.0 中,平台可以调用任一 RK 凭证而无需用户验证。这就意味着,如果你在一些“不规矩”的网站上使用安全密钥,攻击者可以直接把你的安全密钥插入破解器,查看是否有该网站的凭证。CTAP2.1 已对这个问题进行修复,后面会详细讨论。

2、CTAP2.0 缺乏凭证管理:在 CTAP2.0 中,用户无法管理他们的凭证。安全密钥的存储量非常有限,大约 25-50 个凭证,所以空间很快就会被填满。而删除凭证的唯一方法只有重置、抹除整个设备。CTAP2.1 也已解决这个问题。

可发现凭证的未来

CTAP2.1 是 FIDO2 认证器的最新更新,包含四个非常重要的功能,使得可发现凭证可被安全使用:

  1. 用户身份验证逻辑变化:在先前的版本,平台可以静默获取结果,而在 CTAP2.1 中,如果认证器配置了生物特征或 PIN,就无法使用。
  2. CredProtect 扩展:新的凭证保护扩展可用于指定凭证的保护级别。1 级表明凭证可在静默模式使用,2 级表明只有用凭证标识符指明的凭证才能在静默模式使用。可发现凭证会受到用户验证保护。3 级表明可发现凭证和非可发现凭证都会受到用户验证保护。
  3. 凭证管理 API:凭证管理的设备级 API。用户可以删除和修改他们的可发现凭证。如果用户设备中凭证额度不足时,平台会给予通知,用户能够删除未使用的旧凭证。
  4. 认证器配置 API:AuthrConfig API 使得平台实施一些规则与配置,AlwaysUV 就是其中之一,允许用户将设备设置为每一个操作均需要用户身份验证。

上述四项功能的变更还需要 6 到 12 个月(截至 2022 年 1 月)才能实现平台支持,而且支持 CTAP2.1 的安全密钥才刚刚进入市场,所以还需要一定时间才能将一切落实到位。

参考文献

原文地址:https://medium.com/webauthnworks/introduction-to-webauthn-api-5fd1fb46c285

暂无回复。
需要 登录 后方可回复, 如果你还没有账号请 注册新账号