这个 API 很强大,Web 身份认证的未来!

483次阅读  |  发布于3年以前

WebAuthn,即 Web Authentication,是一个用于在浏览器上进行认证的 API,W3C 将其表述为 “An API for accessing Public Key Credentials”,即“一个用于访问公钥凭证的 API”。WebAuthn 很强大,强大到被认为是 Web 身份认证的未来。你有想过通过指纹或者面部识别来登录网站吗?WebAuthn 就能在保证安全和隐私的情况下让这样的想法成为现实。 在这篇文章里,我将会从最基本的概念开始,逐渐深入 WebAuthn,直到解码公钥等深层细节。这可能是你能找到的有关 WebAuthn 最详细最基础的中文文章,也很可能是最长的。如果你只是想了解如何简单地在你的项目中添加对 WebAuthn 的支持,那么 浅谈 WebAuthn 部分就是你要找的;如果你想了解更多关于 WebAuthn 的底层细节,那么你可以继续阅读 深入了解 WebAuthn 部分。那么让我们开始吧。

浅谈 WebAuthn

在这个部分里,我们将会从基础概念开始,了解有关 WebAuthn 和密码学的一些基础知识,并最终使用第三方库实现基础的 WebAuthn 认证。如果你已经对这些内容有所了解了,可以跳到 深入了解 WebAuthn 继续阅读。

为什么使用 WebAuthn

相信你一定收到过类似的邮件吧?只要你点进那个最显眼的链接,你就会进入一个设置好的圈套——钓鱼网站。如果你一时糊涂在这类网站上填写了你的账号和密码,bingo,你的账号就不再是你的账号了。

不过,就算你警惕心再强,也无法避免密码泄露事件。Twitter, Facebook 等都爆出过明文密码泄露事件,证明再大的公司或组织也无法避免密码泄露问题。雪上加霜的是,很大一部分用户都非常喜欢使用重复密码,这就导致一次密码泄露会牵连很多网站,用户的账户安全性完全无法得到保证。

那么,有什么办法解决这些问题吗?彻底解决的方法只有一个,那就是抛弃密码。可是没有密码还怎么验证用户身份呢?这就是 WebAuthn 的用武之地了。

什么是 WebAuthn

那么到底什么是 WebAuthn 呢?如开头所说,WebAuthn 是“一个用于访问公钥凭证的 API”,网站可以通过这个 API 进行一些高安全性的身份验证。WebAuthn 一个最常见的应用就是用于网站登录时的 2FA(双重因素验证)甚至是无密码登录。通过网页调用 WebAuthn,在不同平台下,我们可以实现通过 USB Key、指纹、面部甚至虹膜扫描来认证身份,同时确保安全和隐私。

WebAuthn 标准是 FIDO2 标准的一部分,而 FIDO2 则是由 FIDO 联盟和 W3C 共同推出的 U2F(现称作 FIDO1)的后继标准,旨在增强网络认证的安全性。

你可能了解过 U2F,那么 U2F 和 FIDO2 的区别在哪里呢?从名字上可以看出,U2F,即“通用第二因素协议”,是专注于作为密码后的第二道屏障的,而 FIDO2 增加了单因素认证功能,这意味着使用 FIDO2 可以完全替代密码,真正实现无密码登录。

FIDO2 标准主要包括四个部分,其一是用于网站和访客设备交互的 WebAuthn,而 Client to Authenticator Protocol 2(CTAP2,客户端-认证器协议)作为 WebAuthn 的补充,则是用于访客的设备和认证器交互的协议。标准的其他两个部分则是 U2F 和 UAF 规范。在这篇文章中,我们只关心 WebAuthn,不会涉及 CTAP, U2F 和 UAF 的相关知识。如果你对这段话中的一些概念不了解,不要紧张,接下来我们就来谈谈 WebAuthn 中一些常用的术语和概念。

WebAuthn 只能在安全上下文中使用,也就是说,页面需要使用 HTTPS 协议或是处于 localhost 中。

常用术语和概念

WebAuthn 中有许多不常碰到的术语,不过我只会在这里介绍一些常用的术语和概念——如果你只是希望做出一个简单的实现,那么了解这一段中的一些概念就足够了。同时,由于我实在没能找到一部分术语的通用翻译,有一些术语我按着自己的理解尽可能地翻译了,有任何问题请告诉我。

在一个完整的 WebAuthn 认证流程中,通常有这么几个角色:

认证过程通常分为两种:

同时,认证过程中还会产生这些内容:

请注意区分证明 (Attestation) 和断言 (Assertion),特别是在这两个单词有些相似的情况下。在 WebAuthn 中,它们是不同过程中的类似概念,但并不相同。

如果你对于这些内容不是很理解,没有关系,我们会在 使用流程 一节中将这些概念放到实际情况中解释,你只要先区分这些概念即可。

使用流程

了解了非对称加密,我们就可以来看看 WebAuthn 的认证流程了。

和普通的密码一样,使用 WebAuthn 分为两个部分,注册和验证。注册仪式会在依赖方中将认证器的一些信息和用户建立关联;而验证仪式则是验证这些信息以登确保是用户本人在登录。根据上一节的思路,我们可以知道,注册仪式就是认证器生成一对公私钥,然后将公钥交给依赖方;而验证仪式是依赖方发送给认证器一段文本,要求认证器用自己的私钥加密后发回以验证。

在实际情况中,WebAuthn 是基于挑战-应答模型工作的。要更好地理解,我们直接来看具体流程。先来看看注册的流程。

  1. 浏览器向依赖方发送某个用户的注册请求
  2. 依赖方向浏览器发送挑战、依赖方信息和用户信息
  3. 浏览器向认证器发送挑战、依赖方信息、用户信息和客户端信息以请求创建公钥凭证
  4. 认证器请求用户动作,随后创建一对公私钥,并使用私钥签名挑战(即证明),和公钥一起交给浏览器
  5. 浏览器将签名后的挑战和公钥发送给依赖方
  6. 依赖方用公钥验证挑战是否与发送的一致,如果成功则将公钥与用户绑定,注册完成

而之后的验证流程如下:

  1. 浏览器向依赖方发送某个用户的验证请求
  2. 依赖方向浏览器发送挑战
  3. 浏览器向认证器发送挑战、依赖方信息和客户端信息以请求获取公钥凭证
  4. 认证器请求用户动作,随后通过依赖方信息找到对应私钥,并使用私钥签名挑战(即断言),交给浏览器
  5. 浏览器将签名后的挑战发送给依赖方
  6. 依赖方用之前存储的公钥验证挑战是否与发送的一致,一致则验证成功

WebAuthn 在理论上是安全的,在整个过程中并没有隐私数据被传输——用户信息实际上只包含用户名和用户 ID。因此我们完全可以说 WebAuthn 是安全且私密的。

为了避免用户在不同依赖方之间被追踪,认证器通常会为每个依赖方和用户的组合都创建一对公私钥。不过,由于认证器的存储空间有限,认证器通常不会存储每一个私钥,而是会通过各类信息和烧录在认证器内的主密钥“算”出对应的私钥以实现无限对公私钥。具体算法根据不同厂商会有所不同。

如果依赖方需要,用户同意后,发送给依赖方的公钥凭证中可以包含用于辨认认证器型号的信息,不过这对隐私的影响微乎其微。

浏览器接口

要使用 WebAuthn,我们必须要依靠浏览器作为媒介和验证器进行交互,而这就需要浏览器对于 WebAuthn 的支持了。绝大多数新版本的现代浏览器都为 WebAuthn 提供了统一的接口,而在这一段中我们会了解如何使用相关的接口。但是在开始之前,我们可以先来看看浏览器的支持程度。

浏览器 支持情况
桌面端 Chrome 67+
移动端 Chrome 67+[1]
67+[1] 60+
移动端 Firefox 92+[2]
桌面端 Edge 18+
移动端 Edge 90+[3]
桌面端 Safari 13+
移动端 Safari 13.3+[4]
桌面端 Opera 54+
移动端 Opera 不支持

[1] 受平台限制,Chrome 在 iOS 平台上不支持 WebAuthn,在 Android 平台上支持大部分 WebAuthn 功能,但仍不支持部分特性(如 userVerification)。 [2] 移动端 Firefox 80 以下的版本支持 WebAuthn 但似乎会忽略 authenticatorAttachment 等一部分参数,同时移动端 Firefox Beta 80 以下的版本支持 WebAuthn 但无法成功调用。自 80 版本起移动端 Firefox 暂时取消了对 WebAuthn 的支持,并自 92 版本起重新支持了 WebAuthn。 [3] 低版本的移动端 Edge 似乎支持 WebAuthn 但无法成功调用。 [4] Safari iOS/iPad OS 13 仅支持外部认证器,无法调用 Touch ID 或 Face ID;自 iOS/iPad OS 14 起 Safari 已支持全功能 WebAuthn,可以调用 Touch ID/Face ID

当然,一众国产浏览器,以及 Samsung Browser 和 Yandex Browser,目前都不支持 WebAuthn。此外,由于 WebAuthn 涉及外部验证器和 TPM 可信平台模块等,用户的操作系统也会对 WebAuthn 的可用性造成影响。以下是一些需要注意的信息:

可以看出,WebAuthn 的发展之路仍然很漫长,但好在桌面端对它的支持已经比较完善了,在一些情况下我们完全有理由使用它。


来看看浏览器提供了怎么样的接口吧。要使用 WebAuthn,我们可以使用 navigator.credentials.create() 请求认证器生成公钥凭证和 navigator.credentials.get() 请求获取公钥凭证。

对于一个基础的实现,navigator.credentials.create() 需要传入的参数如下:

navigator.credentials.create({
    publicKey: {
        challenge,
        rp: {
            id,
            name
        },
        user: {
            id,
            name,
            displayName
        },
        pubKeyCredParams: [
            {
                type: "public-key",
                alg
            }
        ],
        authenticatorSelection: {
            authenticatorAttachment,
            userVerification
        },
        excludeCredentials: [
            {
                id,
                transports: [],
                type: "public-key"
            }
        ],
        timeout
    }
})

navigator.credentials.create() 方法中,我们必须传入一个对象,其中只有一对名为 publicKey 的键值。这指明了我们需要创建公钥凭证,而非普通的密码凭证。然后,在 publicKey 对象中设置这些常用参数:

对于 pubKeyCredParams,通常我们只需添加 ES256 (alg: -7) 算法即可兼容大部分外部认证器,此外,再添加 RS256 (alg: -257) 算法即可兼容大部分平台内置认证器(如 Windows Hello)。当然,前端添加算法之后,后端也需要相应的算法支持。

对于 userVerification,由于默认值 “preferred” 并不能很好地被所有设备支持,因此无论在 create() 中还是 get() 中不指定该参数都会在 Chrome 中触发一条警告。

调用 create() 之后,我们就可以拿到一个 Promise,并可以在 then 中获得认证器返回的 PublicKeyCredential 对象。以下是一个 create() 返回的 PublicKeyCredential 对象的例子:

PublicKeyCredential {
    rawId: ArrayBuffer(32) {},
    response: AuthenticatorAttestationResponse {
        attestationObject: ArrayBuffer(390) {},
        clientDataJSON: ArrayBuffer(121) {}
    },
    id: "VByF2w2hDXkVsevQFZdbOJdyCTGOrI1-sVEzOzsNnY0",
    type: "public-key"
}

其中有:

然后将 ArrayBuffer 们以合适的方式编码成字符串,我们就可以把 PublicKeyCredential 发送给依赖方以供验证与注册了。具体怎么操作,我们会在下文详细讨论。当然,别忘了 catch() 注册过程中抛出的任何错误。> 你可能会认为在所有情况下,注册时认证器都会对挑战进行签名。实际上在大部分情况下(同时也是默认情况),注册时认证器并不会对挑战进行签名,attestationObject 并不会包含签名后的挑战。只有依赖方明确要求证明且用户同意(部分浏览器要求)后认证器才会对挑战进行签名(具体实现据情况会有所不同)。对此,MDN 解释道“大部分情况下,用户注册公钥时我们会使用「初次使用时信任模型」(TOFU) ,此时验证公钥是没有必要的。”要了解更多关于证明的内容,请参阅 验证认证器 一节。

而对于 navigator.credentials.get(),我们可以传入如下的参数:

navigator.credentials.get({
    publicKey: {
        challenge,
        rpId,
        userVerification,
        allowCredentials: [
            {
                id,
                transports: [],
                type: "public-key"
            }
        ],
        timeout
    }
})

create() 一样,对于 get() 我们需要传入一个对象,其中只有一对名为 publicKey 的键值,指明我们需要获取的是公钥凭证而非普通的密码凭证。在 publicKey 对象中我们可以设置这些常用参数:

嗯,要传入的参数少多了。之后,和 create() 一样,调用 get() 之后,我们就可以拿到一个 Promise 并在 then 中获得认证器返回的 PublicKeyCredential 对象。以下是一个 get() 返回的 PublicKeyCredential 对象的例子:

PublicKeyCredential {
    rawId: ArrayBuffer(32) {},
    response: AuthenticatorAssertionResponse {
        authenticatorData: ArrayBuffer(37) {},
        signature: ArrayBuffer(256) {},
        userHandle: ArrayBuffer(64) {},
        clientDataJSON: ArrayBuffer(118) {}
    }
    id: "VByF2w2hDXkVsevQFZdbOJdyCTGOrI1-sVEzOzsNnY0"
    type: "public-key"
}

这里的东西就比 create() 时拿到的要多了。看看我们拿到了什么吧:

同样地,我们将 ArrayBuffer 们以合适的方式编码成字符串后就可以把 PublicKeyCredential 发送给依赖方以供验证了。至于具体怎么做,别急,马上就来讲一讲。

简单实现

说了这么多,我们终于可以实现一个简单的 WebAuthn 认证页面了。由于在实际操作中 WebAuthn 相关的数据解码和密码计算比较复杂,在服务器端我们可以使用第三方库来帮我们做这些脏活累活,我们只需专注于具体流程就可以了。

要寻找可用的第三方库,你可以在 webauthn.io 上找到适用于各种语言的第三方库——除了 PHP。不过好在你可以在 GitHub 上找到几个不错的 PHP WebAuthn 库,比如 web-auth/webauthn-framework。

在我们的这个例子中,我们关心的主要是前端逻辑;而后端我们可以使用各类几乎已经做到开箱即用的第三方库,这样我们可以专注于流程而不必关心细节。当然如果你想了解后端的解码细节,可以阅读 手动解个码 一节。


让我们先从注册开始吧。现在,用户点击了注册认证器的按钮,一个请求被发送给服务器(也就是依赖方)。在最简单的情况中,依赖方需要将三个内容发送给浏览器:挑战、用户信息和用户已注册的凭证 ID 列表(即 excludeCredentials)。当然依赖方也可以自由选择发送更多信息,只要最终前端能构建合法的参数即可。

挑战最终会被转换为 Uint8Array,即一组 0-255 的整数。如果使用 PHP,在后端我们可以这样生成 Base64 编码的挑战:

$challenge = "";
for($i = 0; $i < 32; $i++){
    $challenge .= chr(random_int(0, 255));
}
$challenge = base64_encode($challenge);

对于用户信息,我们需要登录名、显示名称和 ID 三项内容。我们可以从数据库中取出用户信息,也可以新建一份。需要注意的是,出于安全和隐私的考量,ID 不应该包含用户的任何信息,比如用户邮箱等。推荐的做法是和挑战一样,生成一个随机字符串/一组随机数,并将其于用户关联起来以供之后使用。

发送已注册的凭证 ID 列表是为了防止用户重复注册同一个认证器。正确设置该列表后,如果用户试图注册同一个认证器,浏览器会中止流程并抛出 InvalidStateError

最后,别忘了将挑战等一些后续可能会用到的信息临时存储起来。Session 就是一个很好的选择。

将所有信息发送到浏览器之后,我们应该可以构建出新建凭证所需的参数了。由于有多个参数需要以 Uint8Array 的形式传入,我们可以准备一个简单的工具函数帮我们将 Base64 的字符串转为 Uint8Array

function str2ab(str){
    return Uint8Array.from(window.atob(str), c=>c.charCodeAt(0));
}

除了 challenge, rp, userexcludeCredentials 几部分需要你根据具体情况设置外,上文提到的其他参数一般可以这么设置:

publicKey: {
    challenge, // 自行设置
    rp, // 自行设置
    user, // 自行设置
    pubKeyCredParams: [
        {
            type: "public-key",
            alg: -7 // ES256
        },
        {
            type: "public-key",
            alg: -257 // RS256
        }
    ],
    authenticatorSelection: {
        userVerification: "discouraged",
        authenticatorAttachment: null // 除非用户指定,大部分情况下无需指定
    },
    excludeCredentials, // 自行设置
    timeout: 60000
}

然后就是传入 navigator.credentials.create(),拿到 PublicKeyCredential。如果一切顺利,接下来我们就需要考虑如何将返回的内容传回依赖方了。由于我们拿到的很多都是 ArrayBuffer,我们需要将其进行编码。再准备一个工具函数吧:

function array2b64String(a) {
    return window.btoa(String.fromCharCode(...a));
}

然后适当处理,我们就可以得到一个方便传输的 JSON 字符串了:

navigator.credentials.create({publicKey}).then((credentialInfo) => ({
    id: credentialInfo.id,
    type: credentialInfo.type,
    rawId: array2b64String(new Uint8Array(credentialInfo.rawId)),
    response: {
        clientDataJSON: array2b64String(new Uint8Array(credentialInfo.response.clientDataJSON)),
        attestationObject: array2b64String(new Uint8Array(credentialInfo.response.attestationObject))
    },
})).then(JSON.stringify).then((authenticatorResponseJSON) => {
    // 可以发送了
}).catch((error) => {
    console.warn(error); // 捕获错误
})

依赖方收到数据以后,还需要做三件事:验证挑战、存储凭证 ID 和存储公钥。如果数据解码顺利,且收到的挑战和之前发送的一致,就可以认为注册成功,将凭证 ID 及公钥与用户关联起来。这一步有很多第三方库可以帮我们做,对于基础实现我们就不深入探究了。

由于不同厂商的认证器的实现方式不同,我们并不能保证凭证 ID 一定是全局唯一的,也就是说,凭证 ID 有可能碰撞——即使这些凭证实际上是不同的。依赖方在实现凭证 ID 的存储及查找时,需要注意和用户 ID 结合进行存储或查找,或是直接在注册认证器时在服务器端对比阻止相同的凭证 ID。


接下来就可以进行验证了。某天,用户点击了验证按钮准备登录,于是浏览器发送了验证请求到依赖方,同时附上要登录的用户名。接下来依赖方至少需要发送两项内容给浏览器:挑战和用户已绑定的凭证 ID 列表(即 allowCredentials)。

之后前端的处理流程就和注册时基本一致了。只是需要注意验证流程中获取到的 PublicKeyCredential 的结构和注册时的稍有不同。

当浏览器将数据传回后,依赖方需要做的事情就比之前要麻烦一些了。依赖方需要验证挑战,并用之前存储的公钥验证签名和签名计数。同样地,这一步有很多第三方库可以帮我们做。最后,如果验证全部通过,我们就可以允许用户登录了。

到目前为止,我们已经实现了一个简单的 WebAuthn 验证服务。不过这只是一个最基础的实现,对于很多高安全要求的身份认证这是远远不够的。因此,我们需要摆脱对第三方库的依赖,深入了解 WebAuthn。你可以继续阅读 深入了解 WebAuthn 部分,不过对于基础的 WebAuthn 实现,我们的旅程就到这里了。

如果你的目标只是快速了解如何开发 WebAuthn,那么你阅读到这里就可以了。同时,上一节例子中的部分代码来自于我为了这篇文章开发的 WordPress 插件 WP-WebAuthn,这个插件可以为你的 WordPress 启用 WebAuthn 无密码登录(并非二步验证)。

如果你正在使用 Chrome 开发,Chrome 87+ 版本添加了一个 WebAuthn 开发者面板,可以帮助你在没有任何实体验证器的情况下开发 WebAuthn 功能。不过,如果你正在使用 Firefox,很遗憾目前我还没有找到对应的开发工具或是浏览器扩展可用。

如果你希望了解更多 WebAuthn 的细节,可以继续往下阅读。

深入了解 WebAuthn

如上文所说,如果摆脱对第三方库的依赖,或是要实现更安全的 WebAuthn,我们必须深入了解 WebAuthn。在这一部分中,我们会详细讨论上文没有提到的一些概念和参数,并了解 WebAuthn 中各类数据的结构以实现解码与验证。先来看一看一些进阶的选项吧。 进阶选项

没错,上文提到的传入 navigator.credentials.create()navigator.credentials.get() 方法的参数其实只是所有参数的一部分。对于 create(),我们还可以配置这些可选内容(上文提及的已省略):

navigator.credentials.create({
    publicKey: {
        rp: {
            icon
        },
        user: {
            icon
        },
        attestation,
        authenticatorSelection: {
            requireResidentKey
        },
        extensions
    }
})

requireResidentKey 设置为 true 可以实现无用户名的登录,即认证器同时替代了用户名和密码。需要注意的是,尽管大部分认证器可以实现无限对公私钥,但能永久存储的私钥数量是有限的(对于 Yubikey,这通常是 25),因此只应在真正需要的时候启用此特性。我们会在 无用户名登录 一节中详细讨论原因。

如果你没有高安全需求(如银行交易等),请不要向认证器索取证明,即将 attestation 设置为 "none"。对于普通身份认证来说,要求证明不必要的,且会有浏览器提示打扰到用户。

Android 暂时无法实施用户验证,进而会导致依赖方验证失败。

对于 extensions,由于目前浏览器支持和应用范围有限,我们不会在这篇文章中涉及,不过你可以看一个例子:

extensions: {
uvm: true, // 要求认证器返回用户进行验证的方法
txAuthSimple: "Please proceed" // 在认证器上显示与交易有关的简短消息
}

对于 get(),我们其实只有一个可选内容没讲了,即 extensions。和上文一样,我们不会在这篇文章中讨论它。 手动解个码

是时候看看如何手动解码了。我们将会在这一节中讨论认证器返回的数据的结构以及如何正确地解码它们。首先我们来看看如何处理注册过程中认证器发回的数据。假设所有 ArrayBuffer 类型的值都被正确地以 Base64 编码,且后端已经将 JSON 的字符串解析为字典。先来复习一下,我们得到的数据应该是这样的(数据较长,已省略一部分):

{
    id: "ZRBkDBCEtq...9XY8atOcbg",
    type: "public-key",
    rawId: "ZRBkDBCEtq...9XY8atOcbg==",
    response: {
        clientDataJSON: "eyJjaGFsbGVuZ2U...i5jcmVhdGUifQ==",
        attestationObject: "o2NmbXRkbm9uZWd...xNHuAMzz2LxZA=="
    }
}

这里的 id 就是凭证的 ID,如果验证正确,我们最终要将它存储起来并于用户关联。同时可以看到 Base64 编码后的 rawId 其实和 id 是一致的(不过 id 是 Base64URL 编码的)。而 type 则一定是 "public-key"。

不过,我们主要关心的还是 respose 中的两项内容。首先是 clientDataJSON。它的处理比较简单,看名字就知道,它应该是一个 JSON 字符串。

小技巧:如果你看到一个 Base64 编码的字符串以 "ey" 开头,那么它大概率是一个 Base64 编码的 JSON。

clientDataJSON Base64 解码再 JSON 解码之后我们就能得到一个字典:

{
    challenge: "NI4i1vsNmP2KHcmyFnBCKRVQPfHgg34SsYZUOPZY2lM",
    extra_keys_may_be_added_here: "do not compare clientDataJSON against a template. See https://goo.gl/yabPex",
    origin: "https://dev.axton.cc",
    type: "webauthn.create"
}

结构一目了然。在这里,我们需要验证三项内容:

同时可以注意到有一个奇怪的 extra_keys_may_be_added_here。这其实是 Google 在 Chrome 中搞的一点小把戏,有一定概率会出现,提醒我们需要将 JSON 解析后再验证键值以防额外插入的键值影响验证。具体信息你可以访问那个 URL 看一看。

对于 Firefox,我们会多得到两项 clientExtensionshashAlgorithm ,分别指明了客户端扩展数据和签名算法。

{
    challenge: "dg6ost6ujhAA0g6WqLe-SOOH-tbhvjW9Sp90aPKlLJI",
    clientExtensions: {},
    hashAlgorithm: "SHA-256",
    origin: "https://dev.axton.cc",
    type: "webauthn.create"
}

由于本文不考虑扩展数据,因此我们可以不考虑 clientExtensions。同时由于目前规范中指定的签名算法只有 SHA-256 一种,因此现阶段我们也可以简单地忽略 hashAlgorithm

clientDataJSON 很简单地就处理完了。接下来我们要来看看 attestationObject。先来看看 attestationObject 的结构图示: attestationObject 是 CBOR 编码后再被 Base64 编码的,因此我们需要额外对其进行 CBOR 解码。

CBOR (Concise Binary Object Representation, 简明二进制对象表示) 是一种多应用于物联网领域的编码方式,你可以将它看作体积更小、更方便物联网传输的二进制 JSON。大部分语言都可以找到对应的 CBOR 解码库。

我们当然不会手解 CBOR,直接来看看解开之后的样子吧:

{
    fmt: "none",
    attStmt: {},
    authData: [211, 217, 43, 24, 199, ..., 97, 238, 166, 67, 107]
}

这些键值的含义如下:

诶,例子里的 attStmt 怎么是空的?还记得之前说的吗?大部分情况下,如果依赖方不要求证明,那么认证器不会签名挑战,于是 fmt 会变为 "none",attstmt 会为空。如果不是高安全要求,我们可以只对这一种情况做支持。

注意,部分情况下 Firefox 会在不要求证明(即 attestation 为 "none")时会返回 fmt 为 "packed" 的证明。这是符合规范的。此时认证器会进行自证明,你可以视情况进行处理。具体可以阅读 验证认证器 一节。

对于非 "none" 的 fmt 我们稍后再谈,现在我们先来看看 authData。来复习一下 authData 的结构: 对于它的解码比较简单粗暴,我们要做的就是根据图示将它切开,然后适当地转换类型。其中各部分的含义如下:- rpIdHash:如其名,SHA-256 的 rpId,长度 32 字节

出于隐私考虑,如果不要求证明,认证器会以 0 填充 AAGUID。

如果你的后端在使用 Node.js,这里有个工具函数可以帮你完成这一步(不考虑 extensions):

function parseAuthData(buffer){
    let rpIdHash = buffer.slice(0, 32);
    buffer = buffer.slice(32);

    let flagsBuf = buffer.slice(0, 1);
    buffer = buffer.slice(1);
    let flagsInt = flagsBuf[0];
    let flags = {
        up: !!(flagsInt & 0x01),
        uv: !!(flagsInt & 0x04),
        at: !!(flagsInt & 0x40),
        ed: !!(flagsInt & 0x80),
        flagsInt
    }
    let counterBuf = buffer.slice(0, 4);
    buffer = buffer.slice(4);

    let counter = counterBuf.readUInt32BE(0);
    let aaguid = undefined;
    let credID = undefined;
    let COSEPublicKey = undefined;
    if(flags.at) {
        aaguid = buffer.slice(0, 16);
        buffer = buffer.slice(16);
        let credIDLenBuf = buffer.slice(0, 2);
        buffer = buffer.slice(2);
        let credIDLen = credIDLenBuf.readUInt16BE(0);
        credID = buffer.slice(0, credIDLen);
        buffer = buffer.slice(credIDLen);
        COSEPublicKey = buffer;
    }
    return {rpIdHash, flagsBuf, flags, counter, counterBuf, aaguid, credID, COSEPublicKey}
}

这段代码来自 https://gist.github.com/herrjemand/dbeb2c2b76362052e5268224660b6fbc

解开后,依赖方至少需要做四件事情:

  1. 验证 rpIdHash 和预期的一致
  2. 按预期检查用户在场和用户验证状态
  3. 存储签名计数
  4. 存储公钥

签名计数不一定从 0 开始。

对于公钥,也就是 credentialPublicKey,我们需要多一次 CBOR 解码,然后就可以得到类似这样的公钥:

{
    kty: "EC",
    alg: "ECDSA_w_SHA256",
    crv: "P-256",
    x: "ZGQALNfqo0L7HFYQHFHCS/X5db49z0ePnuQEs3w3X8w=",
    y: "6qYxhnjYuez/Q8N6vX7nIIGfxFWdZ25NzQfZYuYOalA="
}

然后可以选择适当的方法将其存储起来,之后的步骤本文就不再赘述了。现在,将目光拉回来,让我们看看包含证明的 attestationObject 是怎么样的。我们来看一个例子:

{
    fmt: "packed",
    attStmt: {
        alg: -7,
        sig: [48, 70, 2, 33, 0, ..., 132, 78, 46, 100, 21],
        x5c: [
            [48, 130, 2, 189, 48, 130, 1, 165, 160, 3, ..., 177, 48, 125, 191, 145, 24, 225, 169, 41, 248]
        ]
    },
    authData: [211, 217, 43, 24, 199, ..., 158, 54, 87, 126, 54]
}

这里有一个使用 "packed" 格式的证明。此时,attStmt 中包含三个值:

我们不会在这一节中详述对签名的验证。要了解更多信息,你可以阅读验证认证器一节。现在,让我们来看看如何处理验证过程中认证器发回的数据。我们得到的数据应该是这样的(数据较长,已省略一部分):

{
    id: "hmqdxPLit9...BWeVxZqdvU",
    type: "public-key",
    rawId: "hmqdxPLit9V...BWeVxZqdvU=",
    response: {
        authenticatorData: "09krGMcWTf...UFAAAABA==",
        clientDataJSON: "eyJjaGFsbGVuZ2U...XRobi5nZXQifQ==",
        signature: "UsXZV3pvT3np8btj6V0g...WBkaqyt88DrD40qh+A==",
        userHandle: "MmYxNWYzZjQyZjM...Tg2ZDY4NzhlNw=="
    }
}

id, rawIdtype 和之前一样,这里就不再赘述了。让我们来看看 response。首先是 clientDataJSON,和之前的解法一样,要验证的内容也一样,只是 type 从 "webauthn.create" 变成了 "webauthn.get"。

{
    challenge: "bnkd2CmrEuvKnAFXs2QlC3SKlg4XFvGtP4HJL1yEWyU",
    origin: "https://dev.axton.cc",
    type: "webauthn.get"
}

然后是 userHandle。前面讲过,这是认证器在创建凭证时的用户 ID。如果用户在使用 U2F 认证器,很可能这一项为空,所以大部分情况下我们不关心这一项。

接着来看 authenticatorData。这其实就是之前的 attestedCredentialData,只是这次不包含公钥。以相同的方式切开数据,我们应该可以得到 rpIdHash, flagssignCount 三项。此时,依赖方至少需要做这三样事情:

  1. 验证 rpIdHash 和预期的一致
  2. 按预期检查用户在场和用户验证状态
  3. 验证签名计数大于之前存储的计数,并更新存储的计数

如果签名计数比之前的小,那么这个认证器很可能是伪造的,应该中止验证并返回验证失败。同时,签名计数不一定每次按 1 递增,通常只要计数比此前的大就认为计数检查通过。

最后,我们来看 signature,也就是签名。不过这个签名不是简单的对挑战的签名,具体算法如图所示: 计算签名时,认证器会将 authenticatorDataclientDataHash(也就是 clientDataJSON 的 SHA-256 Hash)拼接起来,并使用对应的私钥签名。依赖方应该使用对应的公钥将其解密,并验证内容是否是 authenticatorDataclientDataHash 的拼接。这部分的计算不在本文的讨论范围内。

最后,如果全部验证通过,返回验证成功。

验证认证器

WebAuthn 已经很安全了,但有的时候我们还要让它更安全一点。比如,如果用户在使用伪造的或是自制的认证器,认证器的安全性就得不到保证。此时,依赖方就需要验证认证器是否是可信的认证器。

这一过程仅发生在注册认证器时。此时,如果认证器验证通过,就可以存储公钥,后续步骤和之前描述的一致。

再次说明,如果不是对安全性有极高的要求,向认证器索取证明以验证认证器是否可信是没有必要的。此外,验证认证器需要依赖方自行维护可信认证器列表,大大增加了维护的复杂性。

在调用 navigator.credentials.create() 时,我们可以将 attestation 设置为非 "none" 来向认证器索取证明。除无证明外,WebAuthn 定义了四种证明方式:

和验证过程一样,这里签名的目标是 authenticatorDataclientDataHash 的连接。

还记得 create()attestation 可选的三个值吗?这个值会决定认证器最终使用哪种方式进行证明。复习一下:

注意,大部分情况下,当认证器需要向依赖方证明自己可信时需要提供认证器公钥,这会触发浏览器提示,只有用户同意后认证器才会进行证明,否则认证器将不提供证明。

为什么浏览器会说“安全密钥的品牌和型号”?事实上,为了避免用户通过认证器证书被跨依赖方追踪,FIDO 要求使用相同认证器证书的认证器的数量不能少于 100,000。于是大部分认证器厂商会选择让同一型号的认证器共用同一份证书。因此,浏览器的会询问用户是否同意“查看安全密钥的品牌和型号”。

Android Safety Net 不会向用户询问是否同意,而是会静默进行证明。

当证明不为空时,依赖方收到数据后根据 attestationObject.fmt 的不同,需要选择不同的验证方式来验证认证器的可信情况。出于篇幅原因,这里我们不会讨论每一种 fmt 的验证方式,更多信息你可以查阅 W3C 文档。

fmtpacked 时,attestationObject.attStmt 可能会有三种格式:

// 自证明
{
    alg, // 算法
    sig // 签名
}
// 基础或证明 CA 证明
{
    alg,
    sig,
    x5c // X.509 证书链
}
// 椭圆曲线证明
{
    alg,
    sig,
    ecdaaKeyId // ECDAA-Issuer 公钥标识符
}

此时,依赖方需要检查证书符合预期格式并检查证书是否在可信链上。首先,如果证明中既没有 ecdaaKeyId 也没有 x5c,就说明这个证明使用的是自证明,只需使用认证器提供的公钥验证即可;如果有 x5c,那么就需要验证 x5c 中的证书是否在可信链上。将 x5c 中的每个证书以 Base64 编码,按 64 个字符切开,并在头尾加上 -----BEGIN CERTIFICATE----------END CERTIFICATE-----就能得到一个证书字符串了。之后,依赖方需要验证证书是否可信。

function base64ToPem(b64cert){
    let pemcert = '';
    for(let i = 0; i < b64cert.length; i += 64){
        pemcert += b64cert.slice(i, i + 64) + '\n';
    }
    return '-----BEGIN CERTIFICATE-----\n' + pemcert + '-----END CERTIFICATE-----';
}

这段代码来自 https://gist.github.com/herrjemand/dbeb2c2b76362052e5268224660b6fbc

至于 ecdaaKeyId,由于目前应用较少,处理方法可能需要你另寻资料了。检查证书的具体步骤已经超出了本文的范围。> 你可以在 FIDO Metadata Service 找到各大厂商认证器的可信证书链。

当在 Android 上调起 WebAuthn 时,大部分情况下 fmt 将会为 safety-net。此时 attestationObject.attStmt 的结构会是:

{
    ver: "200616037",
    response: {
        type: "Buffer",
        data: [101, 121, 74, 104, 98, ..., 115, 104, 104, 82, 65]
    }
}

此时,clientDataJSON 中还会出现 androidPackageName 键,值是调起 WebAuthn 验证的应用的包名,如 Chrome 就是 "com.android.chrome"。

在这个证明中,data 其实是一个 JWT 字符串,我们可以将它编码为字符串并将其按照 JWT 进行解码(别忘了验证 JWT 签名)。最终我们会得到一个类似这样的 Payload:

{
    nonce: "0QAurN4F9wik6GEkblDJhGuf4kuaqZn5zaaxlvD1hlA=",
    timestampMs: 1584950686460,
    apkPackageName: "com.google.android.gms",
    apkDigestSha256: "2BQHno+bmWWwdLUYylS8HLt5ESJzci3nt2uui71ojyE=",
    ctsProfileMatch: true,
    apkCertificateDigestSha256: [
        "8P1sW0EPicslw7UzRsiXL64w+O50Ed+RBICtay2g24M="
    ],
    basicIntegrity: true,
    evaluationType: "BASIC"
}

其中包含了有关设备状态的一些信息。比如说,如果 ctsProfileMatchfalse,那么该设备很有可能被 root 了。对于高安全要求的场景,我们可以视情况进行验证。

同时我们可以在 JWT Header 中验证证明的有效性。我们应该能取得这样的 Header:

{
    alg: "RS256",
    x5c: [
        "MIIFkzCCBHugAwIBAgIR...uvlyjOwAzXuMu7M+PWRc",
        "MIIESjCCAzKgAwIBAgIN...UK4v4ZUN80atnZz1yg=="
    ]
}

这里的结构就和上方的 x5c 验证类似了。

无用户名登录

认证器已经代替了密码,可是这还不够!在进行第一因素认证(即使用 WebAuthn 登录)时,我们还是需要输入用户名,然后才能进行身份认证。懒惰是第一生产力,我们能不能不输入用户名就进行身份认证呢?实际上,大部分认证器都允许我们无用户名登录。而这一特性的核心就是 Resident Key 客户端密钥驻留。

你可以思考一下,为什么普通的 WebAuthn 为什么不能实现无用户名登录?事实上,大部分认证器为了实现无限对公私钥,会将私钥通过 Key Warp 等技术加密后包含在凭证 ID 中发送给依赖方,这样认证器本身就不用存储任何信息。不过,这就导致需要身份认证时,依赖方必须通过用户名找到对应的凭证 ID,将其发送给认证器以供其算出私钥。

Yubikey 实现了一个基于 HMAC 的算法,认证器可以在私钥不离开认证器的前提下(常规的 Key Warp 算法中实际上私钥离开了认证器)通过一些输入和凭证 ID 重新计算私钥。

客户端通过凭证 ID 查找对应认证器的算法根据系统的不同是不同的。通常凭证 ID 中会包含认证器信息,因此系统可以通过凭证 ID 找到对应的认证器。

要避免输入用户名,我们可以要求认证器将私钥在自己的内存中也存储一份。这样,依赖方无需提供凭证 ID,认证器就可以通过依赖方 ID 找到所需的私钥并签名公钥。以下是具体流程:

注册时:

  1. 依赖方请求新建凭证,同时要求启用客户端密钥
  2. 认证器生成一对公私钥,并将私钥存储在永久内存中且与依赖方 ID 及用户 ID 绑定,随后将公钥发送给依赖方以供存储
  3. 依赖方将用户 ID 即公钥与用户绑定

验证时:

  1. 依赖方请求验证,但不必提供除依赖方 ID 以外的更多信息
  2. 用户选择认证器
  3. 认证器根据依赖方 ID 找到对应私钥
  4. 如果有多个对应私钥,认证器会询问用户应该使用哪个身份信息登录
  5. 确定私钥后,认证器签名挑战并将其返回,同时返回用户 ID
  6. 依赖方通过用户 ID 找到对应用户并用对应公钥检查签名,正确则允许对应用户登录

可以看到,这个特性同时要求认证器存储用户 ID,即上面提到过的 userHandle。依赖方需要根据此信息找到对应用户,因此不支持 userHandle 的 U2F 认证器无法进行无用户名登录。

如之前所说,认证器能永久存储的私钥数量是有限的,因此只应在真正需要无用户名登录的时候启用此特性。

目前暂时没有办法检测认证器是否支持客户端密钥驻留,因此在无用户名验证失败时应 fallback 至常规的 WebAuthn 验证,即向用户询问用户名。

现在让我们来看看如何实现这一特性吧。首先,调用 navigator.credentials.create() 时我们需要注意两个参数:requireResidentKey 必须为 trueuserVerification 必须为 "required"。

navigator.credentials.create({
    publicKey: {
        ...
        authenticatorSelection: {
            requireResidentKey: true,
            userVerification: "required"
            ...
        },
        ...
    }
})

Windows Hello 似乎会存储所有已注册的凭据,因此无论是否指定 requireResidentKey,你都可以通过 Windows Hello 进行无用户名登录。

随后,浏览器会询问用户是否允许认证器存储私钥。 如果用户同意,认证器会存储私钥,并和普通的 WebAuthn 一样返回信息。不过,依赖方收到数据之后,只需将公钥、用户 ID 与用户关联起来,而不必再将凭证 ID 与用户关联起来。至此注册完成。

之后,在用户请求登录时,无需再向依赖方提供用户名。同时在传入 navigator.credentials.get() 的参数中也有两个需要注意:userVerification 必须为 "required",同时 allowCredentials 必须为空。

navigator.credentials.get({
    publicKey: {
        ...
        userVerification: "required",
        allowCredentials: [],
        ...
    }
})

Android 暂不支持无用户名验证,空的 allowCredentials 会导致浏览器返回 NotSupportedError 错误。

此时,认证器会根据依赖方 ID 找到对应的私钥。如果有多个对应私钥,认证器会询问用户应该使用哪个身份信息登录。用户选择后,认证器就会使用对应的私钥签名挑战并将其返回。此时,userHandle 一定不为空。

依赖方收到数据后,需要将 userHandle 作为用户 ID 找到对应的用户,并使用对应的公钥验证签名。如果验证成功,则认为对应的用户身份认证成功,依赖方可以允许其登录。至此验证结束。

有的时候你可能会需要清除认证器中的密钥。绝大多数认证器都提供了对应的软件以供清除存储的密钥,但大部分情况下这会重置整个认证器,这意味着相关认证器此前的所有凭证均会失效。因此建议不要将日常使用的认证器作为开发测试用的认证器。

从 U2F 认证迁移

如果你的服务此前提供了 U2F 第二因素认证,你可能会希望在将依赖方从 U2F 升级到 WebAuthn 时用户此前注册的 U2F 认证器仍然可用而无需重新注册。由于 WebAuthn 向后兼容 U2F 设备,用户是可以继续使用原有的 U2F 认证器的。不过,由于 WebAuthn 的依赖方 ID 与 U2F 的 appid 并不一定相同,你需要将原有的 U2F appid 随 WebAuthn 流程一起传递给认证器,认证器才能使用正确的私钥进行响应。

要实现这一点,我们只需要在注册及认证仪式中使用 WebAuthn 的 appid 扩展。

extensions: {
    appid: "https://example.com" //U2F appid
}

此时认证器便可以得到正确的私钥,之后的流程与正常情况一致;依赖方除了正常的 WebAuthn 流程外,不需要再做任何其它操作。

拓展阅读

我们的 WebAuthn 之旅到这里就真的结束了。不过,你仍然可以自行探究,了解更多。本文只是一个粗浅的使用指南,而被称为“Web 身份认证的未来”的 WebAuthn 的深层还要很多细节值得我们挖掘学习,不过本文不会再继续讨论了。

如果你想了解更多关于 WebAuthn 的信息,最好的方法是直接阅读 W3C 规范。此外,Yubico 也提供了不错的 WebAuthn 文档。

如果你想方便地调试 WebAuthn,webauthn.me 提供了一个非常直观方便的调试器。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8