Obeta

WebAuthn介绍与使用

登录功能在日常生活中非常常见,一般情况下我们不会感觉到有什么问题,但是严格来说使用体验与安全方面并不算好。根据微软的调查,去年2018年全球的钓鱼攻击增加了250%,这是一个非常可怕的增长,与此说明大部分用户在安全方面的意识是非常脆弱的,不能仅仅依靠用户自己识别,因此我们需要在技术上来解决这个问题。

webauthn
webauthn
Authenticator(验证者:指纹识别器,PIN 等等), Relying party(服务器), Client(客户端), RP ID(一般为当前域名), crediID(凭证 id,一般为 userid)_

为什么需要 WebAuthn

每年钓鱼网站都在不断的增加与发展,普通用户由于难以分辨会很容易上当,因此每年造成的损失是非常巨大的。最近还看到一个关于暗网的新闻,暗网中存在非常多的菜鸟级钓鱼工具,可以使用各种模板生成亚马逊等购物网站,还会给你挑选类似的域名,一键部署到服务器,价格也非常便宜,100 美元左右,虽然现如今 chrome 与 firefox 都在努力让用户更放心的浏览网页(比如当域名协议为 http 的时候显示不安全1),但是还是无法完全阻止那些黑客的钓鱼攻击。

我们都知道钓鱼攻击是为了获取你的账户名和密码,只要你在钓鱼网站上尝试登录(输入了账户和密码),那后果就只有一个:你号没了。当然账户名被别人拿到了没什么问题,只要我们密码足够安全那几乎没人可以破解的,那么我们如何保护我们的密码呢?

还有一个比较严峻的问题:使用相同的密码。如今使用各种互联网服务都需要提供账户名和密码,相关调查显示平均每个互联网用户多大 90+ 个账户,这么多账户用户不可能每个都设置不同的密码,因此导致了大部分用户的很多账户密码其实都是相同或者类似的,只要其中一个网站不将原始密码进行加密并保护好,那么你的"敏感信息"将被全部泄露。可以参考最近安全事故频发的 FaceBook,爆出的Facebook 明文存储近 6 亿密码虽未泄漏但仍提醒修改密码丑闻让我们认识到即使一个巨无霸公司也还是会犯一些低级错误。

题外话:Chrome 提供了一个 Password Manage 功能(相关 API 为Credential Management API),它会自动检查到当前页面上的注册表单,自动为你生成一个足够强的随机密码,当你再次登录的时候会自动填充。关于Credential Management API的更多相关介绍,可以查看这里

当然 Chrome 这类密码管理功能只能在网页中使用,可以一定程度上防止被钓鱼,市面上也有挺多这类密码管理功能的软件比如 1Password,可应用范围更广,可以覆盖到我们经常使用到的 APP 上。但是使用这些就足够了吗?不一定,因为有密码就不会 100%的安全。

所以我们要干掉密码!

FIDO

FIDO(Fast IDentity Online)是一个安全、开放、防钓鱼、无密码认证标准的联盟,成立于 2013 年,目前在全球拥有 250 多个成员,与 W3C 共同推出了一些安全标准。FIDO 有三个安全协议:UAF,U2F 和 FIDO2。

你可能已经听说过 U2F 协议:

U2F 是严格基于物理设备的 2FA(双因素认证)方案,相对于 OTP(Google Authenticator, Authy, 短信验证码等),设备的交接和管理非常便利,但是需要购买额外的硬件,而且还不兼容移动设备,只有高版本的 Chrome 与 Opera 支持,因此使用上非常不方便2

目前来说应用最广的是 2FA 方案,也就是双因素认证(有些安全性要求高的会需要多因素验证 MFA),比如我们去银行取钱,需要提供两个因素:银行卡和密码,否则无法取款,还有网上的一些注册和登录,有时候需要提供手机号发送验证码,证明你手机在你身旁。

OTP(One-time Password),俗称一次性密码,也是我们常说的动态口令,这个基于一个时间来生成的密码,需要客户端与服务器端保持时间一致,在0->N秒(N 通常为 30 秒至 2 分钟之间的值)内产生的一次性密码是相同的,超过了这个时间就是另外一个密码。当然也有计次的,Google 就有这类备用验证码,提供多个可选值,每个值用一次就失效。

以上常用的一些验证方式虽然目前来说很有效,一定程度上维护了安全,但是体验并不好,而且并不是很安全,比如手机验证码这个看似很安全的验证方式一样可以被攻击,比如 SIM 卡可以被克隆,而 OTP 则需要每次打开相关的软件(Google Authenticator 软件提供这种服务)或者相关的 OTP 设备来获取一次性密码。关于这种类似的讨论可以去通过手机短信验证码验证身份,真的安全吗?看看。

WebAuthn

WebAuthn 是一个无密码登录的协议,既然是无密码,也就是说不会出现重复密码,降低钓鱼网站攻击的概率。它允许浏览器将硬件身份验证设备(USB,蓝牙,NFC,指纹,面容,语音等)提供给 Web 上的站点。这些硬件设备使用户能够在不需要用户名和密码的情况下向网站证明自己的身份。虽然 FIDO 有三个协议,不过都是同一个系列,实现是差多的。

目前只有 Chrome 67+,Firefox 60+和 Edge 17723+中才能使用。如果你是用的最新版带 touchbar 的 MacBook 或者带指纹验证的手机,你可以去这里login demo体验一下 WebAuthn,然后再继续看下去。

体验中你会发现有两个流程,一个是Register,另一个是Login,接下来分别介绍一下。需要注意的是注册和登录流程上是可以不需要账号的,这里需要是因为需要与用户进行匹配,原则上只有 id 是必须填的。

2019-12-25 更新:目前发现在 Android、MacOS 中的 Chrome 支持 WebAuthn API,而 iPhone 与 iPadOS 中的 Chrome 并不能使用(Safari 支持使用 Yubico 但不支持生物验证)

Register

在用户的认知中这是一个注册的过程,但是在技术层这被称之为attestation。过程如下图,图中第一行都是实体部分,也就是客观存在的东西,从左到右依次是:User(用户),UserAgent(客户端),RelyingParty(服务器),Authenticator(验证器)。

WebAuthn 注册

从用户感知方面以下分 5 个流程:

  1. 用户进入注册页面
  2. 用户手动点击注册按钮然后设备提示"是否使用此设备注册到 auth.obeta.me 网站"
  3. 用户点击同意
  4. 手机提示用户授权(使用指纹,PIN,面容识别等)
  5. 网站提示"注册完成"

下面详细介绍每各个关键步骤:

  1. 用户输入用户名点击注册按钮,客户端立即 Post 一个请求到服务器
  2. 服务器生成一个随机的字符串 challenge 与一个 userId,组装成下面的数据格式发送到客户端,如下:
// res 是服务器生成并返回到客户端
const res = {
	rp: {
		name: 'auth.obeta.me',
	},
	user: {
		name: 'zhouyuexie',
		id: 'aNZj4B70MQGMBiYcS_Kt-w', // userId, 需要转换为TypedArray,也就是Unit8Array,在下面转换
		displayName: 'zhouyuexie',
	},
	challenge:
		'ulLqvWUgm28Efb-hVW1w38XW9CxJ9FEOs57ZgH7qkMmQpwSGutKlSo1Zs1fEvBw7z0XGwtRyfntdAqQKlxWJ9w', // challenge, 需要转换为TypedArray,也就是Unit8Array,在下面转换
	pubKeyCredParams: [
		{
			type: 'public-key', // ES256 加密
			alg: -7,
		},
		{
			type: 'public-key', // RS256 加密
			alg: -257,
		},
	],
	authenticatorSelection: {
		requireResidentKey: true, // 是否支持无密码注册登录(注意目前阶段大部分手机设备不支持,因此使用手机注册登录会设置为false)
		// Select authenticators that have a second factor (e.g. PIN, Bio)
		userVerification: 'required',
		// Selects between bound or detachable authenticators
		authenticatorAttachment: 'platform',
	},
	timeout: 60000,
	attestation: 'direct',
};
// 拿到response后在进行下一步之前,我们js还需要对其中的id,challenge进行处理成ArrayBuffer
res.user.id = base64url.decode(res.user.id);
res.challenge = base64url.decode(res.challenge);

// base64url 引用于 https://github.com/herrjemand/Base64URL-ArrayBuffer
  1. 客户端再把这些信息交给 Authenticator,下图是简略的信息,其中的publicKey也就是上面服务器发送过来的res:

WebAuthn 凭证生成
WebAuthn 凭证生成

  1. 然后浏览器会提示用户需要进行验证,并提供多种方式给用户选择,待用户选择相应的验证方式后 Authenticator 通过请求用户进行验证(按下按钮或者使用设备上的 pin、生物特征等),验证完成后,Authenticator 生成一个公钥-私钥对,并存储私钥,用户信息,以及域名。之后再使用私钥对 challenge 进行签名,并将数据返回给客户端(包括 crediID,publicKey,签名).
  2. 客户端将 Authenticator 给的数据(需要将其中的 ArrayBuffer 进行处理成 base64url)再转发给服务器.
  3. 服务器检查该信息,并确保收到的信息(challenge,crediID,签名)与之前发送的信息是一致的(使用公钥解密签名与 cookie 中或者 session 中的 challenge 是否一致).

如果这些步骤中的任何一个失败了,我们就可以认为存在网络钓鱼攻击或者其他的不安全行为

WebAuthn 授权步骤,从左->右
WebAuthn 授权步骤,从左->右

challenge 只需要临时存储,并且每次都随机生成,一般我们保存到 cookie 或者 session 中,待验证过后就把它删掉

此过程并不需要手机或者邮箱,大大简化了注册登录等等需要验证的流程。

Login

用户层面是登录流程,技术层面是assertion。当我需要登录账户,那么我只需要输入用户名并点击登录按钮,再次验证就已经登录完成了.

const res = {
	challenge: 'Ld0vp5byLeFZBOpclgKP3BEc8AA4aBewYPlwbkgLh98',
	allowCredentials: [
		{
			type: 'public-key',
			id: 'SIT9gAgwUyzOLB_F9fA_LwMOu--dcXHSlzvEXipg2QP3-Shr5f-nldK5V1Wc9BdiM', // 用户的credID
			transports: ['internal', 'usb', 'ble', 'nfc'], // 支持的验证器类型
		},
	],
	userVerification: 'required', // 要求验证器在本地验证用户
};

// 同样的id,challenge也需要处理
res.challenge = base64url.decode(res.challenge);
res.allowCredentials[0].id = base64url.decode(res.allowCredentials[0].id);

具体与上面步骤类似,只不过在获取 challenge 的同时,服务器会根据你提供的用户名到数据中查询到对应的 credID,也就是allowCredentials中的id,在第五步的时候服务器将此用户之前注册保存的公钥来进行解密 challenge,如果解密成功说明登录成功。

此过程是我看完了3 中的 Chrome 开发人员的解读后才搞懂,建议都看一下。简单来说就是使用非对称加密来验证身份,私钥存储在验证器中,每次需要鉴权的时候服务器生成一个唯一值给客户端,客户端再丢给验证器,验证器使用私钥加密后返回给服务器,服务器使用存储的公钥进行解密,如果解密成功就证明了这个人的身份,如果解密失败说明身份有问题.

私钥是秘密的,只有用户(或验证者)需要知道它。相反,公钥可以被任何人看到或存储。公钥可用于验证私钥生成的签名。除了私钥之外,没有其他密钥可以生成公钥可以验证为有效的签名。这样,服务器可以存储公钥并使用它来验证由持有私钥的用户执行的签名。

具体的签名流程与解密步骤可以在webauthnsample中看到,这是 Microsoft Edge 团队的一个实验性库,因此可以借鉴使用,但是如果需要生产中使用,建议多测试与多找资料,最好把 w3c 的 webauthn 文档看一遍,助睡眠也是一流...

API 使用

需要更新的是这部分已经被修改,由于本项目代码并不开源,因此可以与webauthnsample配合食用

Web Authentication API 有两个主要调用:navigator.credentials.createnavigator.credentials.get.

create可用于执行注册步骤.get可用于执行身份验证(登录)步骤.在使用 WebAuthn 之前,可能需要一些通用函数来处理数据:

加载Gist中...

以及下面这些 gist 片段,这段 gist 包含了assertion函数,Nodejs 上使用.

加载Gist中...

  1. 关于createEJS 模板中函数的示例调用:
if (!window.PublicKeyCredential) return;

navigator.credentials
	.create({
		publicKey: {
			// random, cryptographically secure, at least 16 bytes
			challenge: str2ab('<%= randomString %>'),
			// relying party
			rp: {
				name: 'obeta.me', // sample relying party, must write
				// icon: "",
				// id: ""
			},
			user: {
				id: str2ab('(^$2342nadfu#*&*)#&@3412b3hs#'), // 不应该包含用户的隐私数据,应该使用user_id这种随机生成的字符串
				name: '<%= name %>', // must write 可以包含电子邮件、用户名、电话号码、服务器认为是主要用户标识符的内容
				displayName: '<%= displayName %>', // must write 显示到屏幕上的名称
			},
			authenticatorSelection: { userVerification: 'preferred' },
			attestation: 'direct',
			pubKeyCredParams: [
				{ type: 'public-key', alg: -7 },
				{ type: 'public-key', alg: -257 },
			], // 服务器支持的签名算法列表.现在,FIDO2服务器被授权支持RS1、RS256和ES256
			timeout: 10000, // 超时时间
			// ..... 其余的可以去 https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialCreationOptions 查看
		},
	})
	.then(res => {
		const json = publicKeyCredentialToJSON(res); // 将其中的ArrayBuffer处理成字符串
		// Send data to relying party's servers
		fetch('/api/webauthn/credential', {
			method: 'post',
			headers: {
				'Content-Type': 'application/json',
			},
			body: JSON.stringify(json)});
	})
	.catch(console.error);

上面publicKeyCredentialToJSON函数接收一个PublicKeyCredential类型的参数,这个参数有以下结构:

const res = {
	id: 'xxxx',
	rawId: new ArrayBuffer(65),
	response: {
		attestationObject: new ArrayBuffer(5574),
		clientDataJSON: new ArrayBuffer(388),
	}, // 响应证明
	type: 'public-key',
};

可以看到上面有三个ArrayBuffer结构,下面来介绍一下attestationObjectclientDataJSON.

attestationObject

一个CBOR编码的证明结构,包含用户公钥、凭据标识符、计数器,以及身份验证者信息,因此我们需要一个CBOR库来decode此数据.

CBOR(Concise Binary Object Representation)翻译中文就是简明二进制对象表示,被广泛应用于物联网领域,可以认为是一种轻量化的二进制 JSON 格式,具体可以查看RFC7049

Decode 其中的 CBOR 得到的结构如下:

WebAuthn attestation object
WebAuthn attestation object

可以搭配此处的w3c-attestationObject文档一起食用

  • fmt是一个证明格式,上面的值是android-safetyne,可选值还有packedfido-u2fnoneandroid-keyandtpm
  • authData包含用户信息的 buffer 结构数据。
  • attStmt是证明语句数据,语句的结构和验证过程取决于fmt定义的格式类型,verversion的简称,不过我们并不关注这个。

如果fmt的值为none,那么说明你不需要用户不需要验证或者你忘记设置attestation: "direct"了。因此这时候就不需要验证了,并不会提供任何证明。此时这个值为android-safetynet,因为我是使用我指纹验证的。

SafetyNet 是一套谷歌游戏服务应用编程接口,有助于防御安卓系统上的安全威胁,如设备篡改、不良网址、恶意应用和虚假用户账户。SafetyNet 提供的主要解决方案是设备证明、安全浏览、重新捕获和应用程序检查 APIs

因此当我使用指纹验证的时候返回的证明格式为android-safetyne,看到attStmt其中的response是一个 ArrayBuffer 结构,我们可以使用上面提供的ab2str函数解码成一个 JWT,解出来的 base64 字符串信息可以点击这里查看

能看到其中的HEADERPAYLOADVERIFY SIGNATURE,其中PAYLOAD如下:

{
	"nonce": "lRKdL9xXvgc7SZ+ERvlqPDGBLPrwv4HE0a0SNzp2TMQ=",
	"timestampMs": 1553580369201,
	"apkPackageName": "com.google.android.gms",
	"apkDigestSha256": "OVc+a/gdjO7ZmZxRj1hGyRcoWvuDUnENMH/r9d4IjpQ=",
	"ctsProfileMatch": true,
	"apkCertificateDigestSha256": [
		"8P1sW0EPJcslw7UzRsiXL64w+O50Ed+RBICtay1g24M="
	],
	"basicIntegrity": true
}

另外的authData是一个 rawBuffer 结构,如下所示:

WebAuthn authdata数据结构
WebAuthn authdata数据结构

我们可以使用 gist 中的parseAuthData工具函数处理它从而得到以下信息:

WebAuthn authdata
WebAuthn authdata

  • rpIdHash: rpid 一般为服务器的域名,比如当前域名为https://obeta.me,那么这个值就是使用 SHA-256 加密obeta.me生成的.
  • flags: 8 个比特位,用于定义身份验证期间身份验证者的状态
  • counter: 一个 4 比特位的计数器,用于检测是否此验证器是否被克隆过,如果此设备的计算器比服务器中的的验证期数少,说明此验证器可能被克隆过.
  • aaguid: 验证器的唯一标志符
  • credID: 凭据 Id
  • COSEPublicKey: COSE编码公钥
// 进一步处理
console.log('CredID: ', base64url.encode(authData.credID));
console.log('AAGUID: ', base64url.encode(authData.aaguid));
console.log('COSEPublicKey', base64url.encode(authData.COSEPublicKey));

更多具体的描述请看文档Authenticator Data

clientDataJSON

是由浏览器生成的 Base64Url 结构,包含来源,challenge,类型.这是一个非常重要的结构,因为客户端数据的验证是确保没有网络钓鱼的第一步.clientDataJSON中的challenge被验证器签名过,因此它受到密钥的保护.

clientDataJSON丢给ab2str函数处理你会得到一个 JSON 字符串:

const str = ab2str(
	'{"type":"webauthn.create","challenge":"NwA2ADcAZQBlADgAYwAwADMANwAzAGYAYQBmAGIAOQBlADEAYQBkADQA","origin":"https://auth.obeta.me","androidPackageName":"com.android.chrome"}'
);
const json_str = JSON.parse(str);
console.log(json_str);
{
	androidPackageName: "com.android.chrome",
 	challenge: "NwA2ADcAZQBlADgAYwAwADMANwAzAGYAYQBmAGIAOQBlADEAYQBkADQA",
 	origin: "https://auth.obeta.me", // 服务器也需要判断此处为当前服务域名
 	type: "webauthn.create",// 因为此处是注册流程,因此应该为 webauthn.create
}

以上只是一些简单的解读,需要了解到完整的服务器端验证可以查看Register a new credential,包含了所有的验证流程,其中此库包含了部分验证代码实现,可以一起参考。

  1. 关于getEJS 模板中函数的示例调用:
navigator.credentials
	.get({
		publicKey: {
			// random, cryptographically secure, at least 16 bytes
			challenge: base64url.decode('<%= challenge %>'),
			allowCredentials: [
				{
					id: base64url.decode('<%= id %>'), // create部分中的credID
					type: 'public-key',
				},
			],
			timeout: 15000,
			// authenticatorSelection: { userVerification: "preferred" }
		},
	})
	.then(assertion => {
		const json = publicKeyCredentialToJSON(assertion); // 将其中的ArrayBuffer处理成字符串
		// Send data to relying party's servers
		fetch('/api/webauthn/assertion', {
			method: 'post',
			header: {
				'content-type': 'application/json',
			},
			body: JSON.stringify(json),
		});
	})
	.catch(err => {
		alert('Invalid FIDO device');
	});

待用户验证后得到的assertion结构如下:

WebAuthn get
WebAuthn get

再使用publicKeyCredentialToJSON处理得到:

WebAuthn get handle
WebAuthn get handle

现在,我们只需要直接使用signatureuserHandleauthenticatorData(与authData数据类似的结构).userHandle是在凭据创建过程中发送的 user.id,在 U2F 中,该字段将始终为空.

总结

不同的验证器(验证设备)返回的数据并不一样,具有格式上的差异,因此需要各种适配,本文章只针对了安卓指纹设备进行了测试,并没有其他相关设备,因此在使用开发前需要自己根据 w3c 的文档来做开发.

移动端很多手机都有指纹解锁了,因此可以直接使用指纹验证,但是对于 PC 桌面端来说那就不够了(除非你有带 touchBar 的 Mac Pro),因此最好单独购买一个密钥验证器,类似于 U 盘的那种,具体可以去yubico官网查看.

有支持 NFC 的,有支持蓝牙的,甚至还有可以当作 SIM 卡插入卡槽的.目前很多国外的公司比如微软,google 等等都支持了 FIDO 验证,因此如果你对安全问题非常关注那么可以考虑购买一个并使用起来.

目前来说 WebAuthn 还是非常新的技术,国内都很少应用此技术的网站,相关的技术文档也并不多,但是不代表人们会忽视它,从长远来看,此技术必会导致新一轮的安全提升,国内大厂也开始重视浏览器安全,比如阿里就在去年加入了 FIDO 联盟.

在正要结尾的时候发现了一个很好用的工具库fido-lib,能大大减少你使用 WebAuthn 时候的工作量,几乎做到了开箱即用的程度,作者是前 FIDO 的技术总监,目前也是 FIDO 的技术顾问,如果你想试试的话可以去看看.

2019-4-15 更新: fido-lib对于 android-safetynet 的支持不够完整,经测试还不可用,至于其他的方式尚无法验证,如果你需要使用,建议直接 clone 这个仓库,因为 npmjs 上的库已经不是最新版的了,会导致一些奇怪的问题.

2019-12-26 更新: 本文已经更新了大部分的实现,这段时间网上已经有挺多基于 FIDO2 的实现,基于前辈们的实现我更新了自己的 Demo,目前可以注册和登录了,话说 Yubico 官网有相关的 Demo 和 Library,还有配套的开发着工具,这个公司正在积极的推动着 FIDO 的发展,如果你想更进一步了解只能去 W3C 查看具体的细节了,描述的非常详细,只是字连起来就看不懂了。。。

引用

个人随笔记录,内容不保证完全正确,若需要转载,请注明作者和出处.