首发于LandØ
如何开发支持 FIDO U2F 登录的网站

如何开发支持 FIDO U2F 登录的网站

前言

U2F (Universal 2nd Factor) 是 Yubico, Yahoo 和 Google 联合开发的基于物理设备的双因素认证协议,目前已经完成标准化,从属于 FIDO (Fast Identity Online) 联盟名下。

特点

相较于其他双因素验证方案,U2F 有以下特点:

  • 优势
    • 相较于 OTP (Google Authenticator, Authy, 短信验证码 等)
      • 操作简单,注册和登录均不需要输入文字/扫描二维码,只需要按一下设备上的按钮
      • 安全性高,私钥明文不会离开设备
    • 相较于其他基于物理设备的方案 (各种 U 盾)
      • 无需驱动/浏览器插件
  • 劣势
    • 需要购买硬件,Yubikey U2F 售价 ¥150 左右,U2FZero 物料费用 $5 左右
    • 不兼容移动设备,只支持桌面浏览器 (Chrome > 49,Opera > 42)

适用场景

U2F 是严格基于物理设备的双因素认证方案,相对于 OTP,设备的交接和管理非常便利,适合大型企业内部系统鉴权(ERP,CRM 等)

此外,U2F 可以作为普通双因素验证方案的补充,为网站用户提供更好的体验(Google,Github, Dropbox,Docker Hub, Salesforce 等网站均已支持)

工作流程

U2F 安全性的核心在于不对称加密算法,私钥保存在设备上,签名运算也在设备上执行,没有任何手段可以获取私钥的明文。因此除非物理上获取到了 U2F 设备,否则是无法是无法破解 U2F 认证流程的。

U2F 的工作流程和常见的不对称加密算法认证体系类似,都是围绕着 “挑战-响应” 展开的。




在硬件层面上,U2F 使用应用广泛的 HID 协议(键盘,鼠标等),支持 USB、蓝牙 和 NFC,确保在多种操作系统上,无需驱动,即插即用。

在浏览器层面上,Chrome 将 HID 协议封装成底层 JavaScript API。

FIDO 官方提供了 u2f-api.js 将浏览器底层 API 封装成高层 API,通过一两个调用,即可完成注册和认证操作。

第三方封装的高层 API会有不同的实现,了解 Yubico u2f-api.js 有助于了解 U2F 开发的细节。

代码实现

注册

首先导入 demo.yubico.com/js/u2f- ,详细的文档可以在 这里 查阅

典型的 U2F 注册流程,应该发生在用户已经完成 用户名/密码 注册之后,将 U2F 设备绑定到一个用户名下。

首先,后端生成随机字符串,作为挑战,记录下来并发送到前端。

然后,前端使用 u2f 对象,完成签名

// AppId, 网站的 HTTPS 基地址
var appId = https://demo.yubico.com”;

// 构建参数
var params = {  
 // 后端发送的随机字符串,作为挑战
 challenge: XXXXXXXXXXXXXXXX,
 // U2F 协议版本号,固定值
 version: U2F_V2
};

// 调用 u2f.register
u2f.register(params.appId,  
            [params],
            function(data) {
});

返回值 data 是一个字典

返回值 data 是一个字典。发生错误的情况下,data 只有 errorCode 字段,定义如下(来自文档)

interface ErrorCode {  
    const short OK = 0;
    const short OTHER_ERROR = 1;
    const short BAD_REQUEST = 2;
    const short CONFIGURATION_UNSUPPORTED = 3;
    const short DEVICE_INELIGIBLE = 4;
    const short TIMEOUT = 5;
};

正常情况下,data 包含如下内容

{
  // 与服务器发起的 Challenge 内容相同
  challenge: "xxxxxxxxxxxxxxxxxxxxxxxxx",
  // 见下文
  clientData: "xxxxxxxxxxxxx",
  // 见下文
  registrationData: "xxxxxxxxxxxxxx",
  version: "U2F_V2"
}

clientData 为 Base64 编码后的 JSON 字符串

{
  // 固定值, typ 没拼错
  typ: "navigator.id.finishEnrollment", 
  challenge: "xxxxxxxxxxxxxxxxxxxxxx",
  origin: "https://demo.yubico.com"
}

registrationData 为 Base64 编码后的二进制数据,内容按顺序如下

  • Head
    • 1 字节,固定值为 0x05
  • PubKey
    • 65 字节,应用证书公钥,无压缩 P-256 NIST 椭圆曲线坐标数据
  • PrivKeyHandle_Len
    • 1 字节,无符号整数,KeyHandle 的长度
  • PrivKeyHandle
    • 长度由 KeyHandle_Len 决定,私钥句柄,见下文
  • Main_PubKey
    • 长度不定,设备主证书公钥,X.509 DER 二进制编码的证书,同一批设备可能共用一个主证书
  • Sig
    • 长度不定,签名,使用 SHA256-ECDSA (P-256 NIST) 算法

Sig 使用 Main_PubKey 签名,原始内容如下(拼接二进制数据)

  • 1 字节固定值,0x00
  • SHA256(AppId)
  • SHA256(ClientData)
  • PrivKeyHandle
  • PubKey

最终,后端在验证完签名后,将 PrivKeyHandle, PubKey 和 Main_PubKey 记录下来,并与用户关联,用于日后的验证。

验证

后端生成随机数,作为挑战,并记录下来,然后和 PrivKeyHandle 一起,发送到前端。

前端调用 u2f.sign 方法,执行验证操作。

// AppId, HTTPS 基地址
var appId = "https://demo.yubico.com";  
// Challenge, 后端生成的随机数
var challenge = "xxxxxxxxxxxxxxxxx";  
// 参数
var params = {  
  // U2F 协议版本号,固定值
  "version": "U2F_V2",
  // PrivKeyHandle, 先前记录的私钥句柄
  "keyHandle": "XXXXXXX"
};
// 调用 u2f.sign
u2f.sign(appId, challenge, [params], function(data) {  
});

和注册类似,在失败的时候,data 包含一个 errorCode

成功的时候,data 包含如下内容

{
  // PrivKeyHandle, Base64 编码
  "keyHandle":"xxxxxxxxxxxx",
  // 见下文
  "clientData":"xxxxxxxxxxxx",
  // 见下文
  "signatureData":"xxxxxxxxxx"
}

其中,clientData 字段为 Base64 编码后的 JSON

{
  // 固定值,typ 没拼错
  typ: "navigator.id.getAssertion", 
  // 先前服务器发起的 Challenge
  challenge: "xxxxxxxxxxxxxxxxxxxxxx", 
  origin: "https://demo.yubico.com"
}

signatureData 字段为 Base64 编码后的二进制数据,内容按顺序如下:

  • Flag
    • 1 字节,第 0 比特位 表示认证是否成功
  • Counter
    • 4 字节, Big-Endian 无符号整数 (UInt32),签名计数器
  • Sig
    • 长度不定,SHA256-ECDSA (P-256 NIST) 签名

Sig 使用 PubKey 签名,原始数据如下(拼接二进制数据):

  • SHA256(AppId)
  • Flag
  • Counter
  • SHA256(ClientData)

最终,服务器在验证完签名后,认可用户的身份,执行下一步操作。

在整个流程中,为了防止设备在不同的网站间追踪,U2F 设备会为不同的网站生成不同的密钥对。为了保证单台 U2F 设备支持无限多的网站登录,密钥对中的私钥保存在 U2F 设备上是不可能的,因为芯片容量有限,且非常珍贵,因此才有了 PrivKeyHandle 这一参数。PrivKeyHandle 是用来让 U2F 设备“回想”起私钥的,而具体的内部实现方式,由各厂商自己决定。

在最简单的实现方式中,U2F 设备上保存一个主密码,该密码不可从外部读取。注册时生成的私钥经主密码对称加密后,作为 PrivKeyHandle 发送给服务器。在验证的时候,服务器把 PrivKeyHandle 发送回 U2F 设备,U2F 设备用主密码解密私钥,并完成签名。

而 Yubikey 和 U2FZero (以及其他厂商) 使用了一个更加复杂的方案,私钥由 随机数、AppId、设备主密码经过复杂的算法派生出来,PrivKeyHandle 中只包含一个 MAC (Message Authentication Code) 和随机数,保证私钥不会离开设备。

参考资料:


Yubico’s Take on U2F Key Wrapping | Yubico

Yubico/java-u2flib-server: Java server-side library for U2F

fidoalliance.org/specs/

我的博客链接: 如何开发支持 U2F 的网站

编辑于 2017-02-23

文章被以下专栏收录