FIDO与WebAuthn


说在最前面:目前实现fido的困难点在于

1、虽说目前发布了W3C规范,但仍然存在兼容问题,不同的浏览器对相关的特性支持情况不一且支持的浏览器不多

2、需要认证器,而认证器成本比较高;若使用内置认证器,只有手机和部分pc支持指纹等生物识别

3、认证器需要与用户设备交互,而交互需要有api,目前在不开发的情况下,只有部分主流浏览器支持和鸿蒙,windows10 1903以上版本,以及MacOs支持;目前这个是主要难点,若将fido集成在应用上,需要集成fidosdk;

4、fido目前,由于为了实现无限公私钥对,使用固定算法+烧录认证器硬件上的唯一私钥+凭证id生成公私钥,据阿里巴巴安全工程师指出这种方式存在安全风险,可破译伪装;阿里巴巴技术专家:U2F存在风险可被钓鱼克隆,服务提供商需做对应检测 (sohu.com)

FIDO

背景

目前流行的身份认证方式或多或少存在问题。

传统静态密码认证存在的安全问题:

  • 通过黑客技术盗取用户帐号密码,如:拖库、钓鱼、木马、暴力破解等。
  • 撞库:利用大多数人在所有服务使用同一套帐号密码的特点,使用已经获得的帐号密码尝试登录其他服务。

动态认证方式存在的安全问题:

  • 恶意应用可以读取短信验证码,在用户不知情的情况下进行盗刷。
  • 动态令牌、U盾在各个机构间不通用,用户需要持有多个硬件设备。
  • 短信验证码依赖信任手机和SIM卡以及运营商基站,手机和SIM可能丢失或被盗,基站存在被伪造,甚至通过钓鱼网站、中间人攻击等手段获取用户正确的验证码,安全性大打折扣。

生物特征认证存在的安全问题:

  • 生物特征独一无二,被采集到服务端,无法像密码一样更改,泄露后果严重。
  • 只在移动端(手机和部分笔记本电脑)普及,桌面端基本无法使用。

使用静态密码的不足

1)静态密码的易用性和安全性互相排斥,两者不能兼顾,简单容易记忆的密码安全性弱,复杂的静态密码安全性高但是不易记忆和维护;

2)静态密码安全性低,容易遭受各种形式的安全攻击;

3)静态密码的风险成本高,一旦泄密将可能造成最大程度的损失,而且在发生损失以前,通常不知道静态密码已经泄密;

4)静态密码的使用和维护不便,特别一个用户有几个甚至十几个静态密码需要使用和维护时,静态密码遗忘及遗忘以后所进行的挂失、重置等操作通常需要花费不少的时间和精力,非常影响正常的使用感受。

而fido可省去输入账户、密码、验证码等环节

为了进一步提高账户安全性,双因素身份认证问世了。最普遍的2FA方式就是短信验证码、OTP动态令牌、基于USBKey的CA认证等等。

1)短信验证码依赖信任手机和SIM卡以及运营商基站,手机和SIM可能丢失或被盗,基站存在被伪造,甚至通过钓鱼网站、中间人攻击等手段获取用户正确的验证码,安全性大打折扣;

2)OTP动态令牌,UsbKey CA证书使用独立硬件作为身份认证的入口,要随身带硬件设备并且依赖负责的后端服务器来管理,成本非常大使用很不方便;没有标准各个厂商各自维护自有协议。

FIDO是什么

FIDO全称为Fast Identity Online,是一套身份认证框架协议。它由FIDO联盟推出并持续维护;

它的目标是创建一套开放、可扩展的标准协议,支持对Web应用的非密码安全认证,消除或减弱用户对密码的依赖。

fido发展至今共有两个版本,fido1.0和fido2.0

fido1.0

​ 主要是通过两个标准协议来实现安全登录(验证):分为两大类U2F和UAF。

​ U2F类似于国内的二代U盾的保护机制,相当于双因素认证。使用双因子(密码和能与用户交互的设备)保护用户账户个隐私。当进行验证时,服务器在合适的时候,提示用户插入设备并进行按键操作,加密设备对数据签名,发送给服务器,服务器做验证,如果验证成功,用户则可登录成功。由于有了第二因子(加密设备)的保护,用户可以选择不设置密码或者使用一串简单易记的4位密码。
​ UAF则是支持指纹,语音,虹膜,脸部识别等生物身份识别方式。无需用户密码介入,直接进行验证交易。用户在注册阶段,根据服务器支持的本地验证方式,选择一种验证方式,如指纹识别,人脸识别,语音识别等等,服务器也可以保留密码验证方式,将密码和生物相结合,增强账户安全性。

​ fido1.0为无密码/2fa 体验创建跨行业标准的第一次迭代(使用 UAF 和 U2F),此时还未成为相关国际标准制定机构

fido2.0

与1.0区别

区别就是,FIDO2 是由 FIDO 联盟和 W3C 共同推出的 U2F(现称作 FIDO1)的后继标准,旨在增强网络认证的安全性,使用CTAP(身份验证器 API)和Webauthn(浏览器 API)实现U2F;且FIDO2 增加了单因素认证功能,也就是不再需要用户名密码,真正实现无密码登录。

也就是完善了U2F且1.0还未形成规范,而2.0之后统一了规范,制定了身份验证器和浏览器(用户端)的API。

FIDO2:第二次迭代(使用 CTAP 和 Webauthn)

  • U2F:为 FIDO 指定 Javascript API 和 HID 协议
  • CTAP:为 FIDO2 指定 HID 协议(身份验证器 API)
  • Webauthn:为 FIDO2 指定一个 Javascript API (浏览器 API)W3C 标准

2019年3月4日万维网联盟(W3C)宣布:Web身份验证API(WebAuthn)现在已成为官方Web标准。

2019年正式发布的FIDO2包含CTAP(Client to Authenticator Protocols)和W3C WebAuthn两部分,是1.x版本的演进,向后兼容1.x版本。

  • CTAP:允许通过USB、NFC或BLE使用外部身份验证器(比如FIDO安全密钥,移动设备)在启用FIDO2的浏览器和操作系统上进行身份验证,以提供无密码、二次身份验证或多重身份验证体验。
  • W3C WebAuthn:定义了内置在浏览器和平台中的标准Web API,以支持FIDO身份验证。

FIDO2 标准主要包括四个部分

其一是用于网站和访客设备交互的 WebAuthn,

而 Client to Authenticator Protocol 2(CTAP2,客户端-认证器协议)作为 WebAuthn 的补充,则是用于访客的设备和认证器交互的协议。

标准的其他两个部分则是 U2F 和 UAF 规范。

我们只关心 WebAuthn,不会涉及 CTAP, U2F 和 UAF 的相关知识。

FIDO2能做什么

1、保证用户认证的安全和隐私。

2、无用户名密码登录

3、解决背景中提到的所不能的或存在的缺陷

FIDO2实现

介绍一些概念

1、什么是WebAuthn

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

在这里插入图片描述

图一:是window1903以上版本提供的window Hello

图二三:是浏览器调用WebAuthn API拉起的访问认证器的交互页面


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


2、FIDO2的组件

FIDO2包括3个组件:FIDO2认证器、FIDO2客户端和FIDO2服务器。

  • FIDO2认证器

    用来进行本地认证的机制或设备,又分为FIDO2平台认证器和FIDO2漫游认证器两种。在面向最终用户时,认证器通常被称为安全密钥。

    • FIDO2平台认证器:集成在使用FIDO2的设备上的认证器,比如手机或笔记本电脑上的指纹认证器。
    • FIDO2漫游认证器:游离于使用FIDO2的设备,通过蓝牙、NFC或USB连接的认证器,比如形状类似于U盾或动态令牌的认证器。
  • FIDO2客户端:集成在平台(如Windows、MacOS和HMS Core)中,提供SDK给应用集成;或集成在浏览器中(如Chrome、Firefox和华为浏览器),提供JavaScript API给服务集成。FIDO2客户端是应用调用FIDO2服务器和FIDO2认证器完成认证的桥梁。

  • FIDO2服务器:在应用服务器需要发起FIDO2认证时,生成符合FIDO2规范的认证请求,发送给应用服务器;并在FIDO2认证器完成本地认证后,接收应用服务器返回的FIDO2认证响应,并进行校验。

3、常用术语和概念

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

  • Relying Party 依赖方 (RP) :指服务提供方,即网站,也就是FIDO2服务器
  • Authenticator 认证器:通常指 USB Key 或是设备内置的指纹扫描器、虹膜扫描器、面部识别装置等,正是它们在使用流程中代替了密码甚至是用户名,也就是FIDO2认证器
  • User Agent 用户代理:通常指浏览器或系统,负责与认证器交互,也就是FIDO2客户端
  • User 用户:正准备登录的你

认证过程通常分为两种:

  • Registration Ceremony 注册仪式:用户向账户上添加认证器
  • Authentication Ceremony 验证仪式:用户通过已注册的认证器验证身份

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

  • Challenge 挑战:通常是一串随机字符串
  • Public Key Credential 公钥凭证:由认证器产生的凭证,在技术上代替了密码
  • Attestation 证明:注册时认证器产生的验证数据
  • Assertion 断言:验证时认证器产生的验证数据

使用流程

1、使用之前必须知道的

要使用 FIDO2,我们必须要有FIDO客户端,而客户端一般是OS平台和浏览器平台;

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

浏览器支持情况
桌面端 Chrome67+
移动端 Chrome67+[1]
桌面端 Firefox60+
移动端 Firefox92+[2]
桌面端 Edge18+
移动端 Edge90+[3]
桌面端 Safari13+
移动端 Safari13.3+[4]
桌面端 Opera54+
移动端 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 的可用性造成影响。以下是一些需要注意的信息:

  • Windows 10 1903 以下版本仅 Edge 能提供完整支持,其他浏览器只能使用 USB Key 等外部认证器;1903+ 中所有浏览器都可以通过 Windows Hello 带来完整的 WebAuthn 支持
  • Android 需要安装并开启 Google 服务
  • iOS/iPad OS 13.3 以下的版本不支持 WebAuthn,iOS/iPad OS 14 以下的版本支持有限(参考上文),iOS/iPad OS 14 开始 Safari 已支持全功能 WebAuthn(功能完整度甚至超过了 Android)

仍要注意某些特性不同浏览器的表现各不相同,虽不相同但不影响主要流程

WebAuthn

和普通认证一样,WebAuthn 分为两个部分,注册和验证。

注册会在依赖方中将认证器的一些信息和用户建立关联;

验证则是验证这些信息以登确保是用户本人在登录。

在这里插入图片描述

1、用户点击注册按钮,交互应用服务器应请求后台服务器

2、后台服务器根据用户名找到用户id,将用户id和服务器id和一个随机字符串(挑战)返回

3、交互应用服务器组装参数,调用webAuthn的create接口与认证器交互

4、执行create方法,首先检测设备是否存在认证器,不存在会提示用户插入

5、认证器首先要求用户验证身份,UAF认证器通过生物识别认证,U2F认证器通过4位PIN码

6、认证器会根据生成随机的凭证id,并根据凭证id和刻录在认证器硬件上的唯一私钥使用特定算法生成一对公私钥,并使用私钥加密挑战,并返回公钥,凭证id,加密后的挑战

7、浏览器传递返回值

8、后台验证挑战,保存凭证id和公钥,将公钥和凭证id关联该用户,fido认证注册成功;

验证流程
1、用户输入用户名,点击登录,浏览器请求后台服务器获取参数

2、后台服务器根据用户名返回凭证id,并附上自己的服务器id以及挑战

3、浏览器组装参数,调用webAuthn接口的get方法

4、执行get方法,首先检测是否存在认证器,无则要求插入

5、认证器响应方法,根据凭证id使用特定算法和唯一私钥生成公私钥,使用私钥加密挑战,将加密后的挑战和公钥返回

6、后台服务器解析返回值,验证挑战;匹配则登录成功

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


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



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



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


注册

要使用 WebAuthn,我们可以使用 navigator.credentials.create() 请求认证器生成公钥凭证

navigator.credentials.create() 需要传入的参数如下:

navigator.credentials.create({
    publicKey: {
        challenge,//一个随机字符串,用来验证的,长度至少16,需要转换 Uint8Array
        rp: {
            id,//(可选)依赖方 ID,必须为当前域名或为当前域名的子集的域名,不指定默认为当前域名
            name,//依赖方名称,用于方便用户辨认
            icon//(可选)不必要的,依赖方图标
        },//依赖方信息
        user: {
            id,//用户的唯一标识,需要转换为Uint8Array 的字符串
            name,//登录用户名
            displayName,//用于显示的用户名称,显示与否的具体行为取决于浏览器
            icon//(可选)不必要的,用户图标
        },//用户信息
        pubKeyCredParams: [
            {
                type: "public-key",
                alg//一个负整数,用于标明算法。算法对应的数字可以在 COSE 找到
            }
        ],//一个算法列表,指明依赖方接受哪些签名算法
        authenticatorSelection: {
            authenticatorAttachment,//String(可选)指定要求的认证器类型。
            userVerification,//String(可选)指定认证器是否需要验证
            requireResidentKey//Boolean(可选)是否要求将私钥钥永久存储于认证器中
        },//(可选)用于过滤正确的认证器
        excludeCredentials: [
            {
                id,//要排除的凭证ID
                transports: [],//通信方式
                type: "public-key"//只能为这个值
            }
        ],//(可选)用于标识要排除的凭证,避免同一个用户多次注册同一个认证器。
        timeout,//Number类型(可选),方法超时时间的毫秒数,超时后将强制终止 create() 并抛出错误。不设置将使用浏览器的默认值,推荐值5000-120000
        extensions:{},//WebAuthn 扩展,可以提供规范之外的配置和响应
        attestation//(可选)String:表明依赖方是否需要要求认证器证明自己
    }
})

参数详细解释

  • challenge: Uint8Array:转换为 Uint8Array 的挑战,长度至少为 16,建议为 32
  • rp: Object:依赖方信息,其中有一项为必须:
    • rp.id: String:(可选)依赖方 ID,必须为当前域名或为当前域名的子集的域名(不是子域名)。如域名为 test.123.example.com,则依赖方 ID 可以是 test.123.example.com, 123.example.com 或 example.com。不指定则默认使用当前域名
    • rp.name: String:依赖方名称,用于方便用户辨认
    • rp.icon:String: (可选)为依赖方和用户设置图标,可以使用 HTTPS URL 或 Base64,一般长度不能超过 128 字节,不过大部分浏览器不会显示图标,或是有非常小的图片体积限制,所以一般没有使用这一属性的必要
  • user: Object:用户信息,其中有三项为必须:
    • user.id: Uint8Array:转换为 Uint8Array 的字符串。出于安全考量,这应尽可能不与任何用户信息相关联,如不要包含用户名、用户邮箱等
    • user.name: String:登录用户名
    • user.dispalyName: String:用于显示的用户名称,显示与否的具体行为取决于浏览器
  • pubKeyCredParams: Array:一个算法列表,指明依赖方接受哪些签名算法。列表的每一项都是一个对象,拥有两个属性:
    • pubKeyCredParams[].type: String:值只能为 “public-key”
    • pubKeyCredParams[].alg: Number:一个负整数,用于标明算法。具体算法对应的数字可以在 COSE 找到
  • authenticatorSelection: Object:(可选)用于过滤正确的认证器,这里介绍常用的一些参数:
    • authenticatorSelection.authenticatorAttachment: String:(可选)指定要求的认证器类型。如果没有满足要求的认证器,认证可能会失败。该参数可以为 null(表示接受所有类型的认证器)或是以下两个值之一:
      • platform:表示仅接受平台内置的、无法移除的认证器,如手机的指纹识别设备
      • cross-platform:表示仅接受外部认证器,如 USB Key
    • authenticatorSelection.userVerification: String:(可选)指定认证器是否需要验证“用户为本人 (User Verified, UV)”,否则只须“用户在场 (User Present, UP)”。具体验证过程取决于认证器(不同认证器的认证方法不同,也有认证器不支持用户验证),而对验证结果的处理情况则取决于依赖方。该参数可以为以下三个值之一:
      • required:依赖方要求用户验证
      • preferred:(默认)依赖方希望有用户验证,但也接受用户在场的结果
      • discouraged:依赖方不关心用户验证。对于 iOS/iPad OS 13,必须设置为此值,否则验证将失败
    • authenticatorSelection.requireResidentKey: Boolean:是否要求将私钥永久存储于认证器中。默认值为 false。对于 iOS/iPad OS 13,必须设置为 false,否则验证将失败
  • excludeCredentials: Array:(可选)用于标识要排除的凭证,可以避免同一个用户多次注册同一个认证器。如果用户试图注册相同的认证器,用户代理会抛出 InvalidStateError 错误。数组中的每一项都是一个公钥凭证对象,包含以下属性:
    • excludeCredentials[].type: String:值只能为 “public-key”
    • excludeCredentials[].id: Uint8Array:要排除的凭证 ID
    • excludeCredentials[].transports: Array:(可选)用于指定该凭证所需的认证器与用户代理的通信方式,可以包含以下的一或多个字符串:
      • usb:可以通过 USB 连接的认证器
      • nfc:可以通过 NFC 连接的认证器
      • ble:可以通过蓝牙连接的认证器
      • internal:平台内置的、无法移除的认证器
  • timeout: Number:(可选)方法超时时间的毫秒数,超时后将强制终止 create() 并抛出错误。若不设置,将使用用户代理的默认值;若太大或太小,则使用最接近的用户代理默认值范围中的值。推荐值为 5000-120000
  • attestation: String:表明依赖方是否需要证明。可选三个值:
    • none:(默认)不需要证明。如上文所述,依赖方不关心证明,因此认证器不会签名。对于 iOS/iPad OS 13,必须设置为此值,否则验证将失败
    • indirect:依赖方需要证明,但证明方式可由认证器选择。在支持匿名证明的认证器上,认证器会通过匿名证明的方式签名挑战,并向依赖方提供签名方式等信息
    • direct:依赖方要求直接证明。此时认证器会使用烧录在认证器中的公钥进行签名,同时向依赖方提供签名方式等信息以供依赖方验证认证器是否可信。更多信息可以阅读“验证认证器”一节
  • extensions: Object:WebAuthn 扩展,可以提供规范之外的配置和响应。由于实际情况中很少会使用这一特性,不讨论;

注意:

​ 1、对于 pubKeyCredParams,通常我们只需添加 ES256 (alg: -7) 算法即可兼容大部分外部认证器,此外,再添加 RS256 (alg: -257) 算法即可兼容大部分平台内置认证器(如 Windows Hello)。

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

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

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

create结果返回

调用 create() 之后,我们就可以拿到一个 Promise,并可以在 then 中获得认证器返回

PublicKeyCredential 对象。

以下是返回的PublicKeyCredential 对象的例子:

PublicKeyCredential {
    rawId: ArrayBuffer(32) {},//ArrayBuffer 的原始凭证 ID
    response: AuthenticatorAttestationResponse {
        attestationObject: ArrayBuffer(390) {},
        clientDataJSON: ArrayBuffer(121) {}
    },//是返回的主要部分
    id: "VByF2w2hDXkVsevQFZdbOJdyCTGOrI1-sVEzOzsNnY0",//Base64URL 编码的凭证 ID
    type: "public-key"//一定是public-key
}

其中有:

  • id: String:Base64URL 编码的凭证 ID
  • rawId: ArrayBuffer:ArrayBuffer 的原始凭证 ID
  • type: String:一定是 “public-key”
  • response: Object:AuthenticatorAttestationResponse 对象,是 PublicKeyCredential 的主要部分,包含以下两个内容:
    • response.clientDataJSON: ArrayBuffer:客户端数据,包含 origin(即凭证请求来源)、挑战等信息
    • response.attestationObject: ArrayBuffer:CBOR 编码的认证器数据,包含凭证公钥、凭证 ID、签名(如果有)、签名计数等信息

ArrayBuffer 们以合适的方式编码成字符串,我们就可以把 PublicKeyCredential 发送给依赖方以供验证与注册了。别忘了 catch() 注册过程中抛出的任何错误。

注意:

​ 并不是在所有情况下,注册时认证器都会对挑战进行签名。实际上在大部分情况下(同时也是默认情况),注册时认证器并不会对挑战进行签名,attestationObject 并不会包含签名后的挑战。只有依赖方明确要求证明且用户同意(部分浏览器要求)后认证器才会对挑战进行签名(具体实现据情况会有所不同)。对此,MDN 解释道“大部分情况下,用户注册公钥时我们会使用「初次使用时信任模型」(TOFU) ,此时验证公钥是没有必要的。”

验证

使用navigator.credentials.get()获取认证器信息进行验证;

navigator.credentials.get({
    publicKey: {
        challenge,//转换为Uint8Array的挑战,长度至少为 16,建议为 32
        rpId,//(可选)依赖方id,要和注册认证器时的一致,不指定默认使用当前域名
        userVerification,//指定认证器是否需要验证 和注册是一样
        allowCredentials: [
            {
                id,//允许的凭证 ID
                transports: [],//与create一样
                type: "public-key"
            }
        ],//(可选)用于标识允许的凭证 ID,使用户代理找到正确的认证器。只有符合这个列表中凭证 ID 的凭证才能被成功返回。
        timeout//(可选)方法超时时间的毫秒数
    }
})

和注册一样,对于get()我们需要传入一个对象,其中只有一对名为 publicKey 的键值,指明我们需要获取的是公钥凭证而非普通的密码凭证。

get结果返回

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

以下是一个 get() 返回的 PublicKeyCredential 对象的例子:

PublicKeyCredential {
    rawId: ArrayBuffer(32) {},//ArrayBuffer 的原始凭证 ID
    response: AuthenticatorAssertionResponse {
        authenticatorData: ArrayBuffer(37) {},
        signature: ArrayBuffer(256) {},
        userHandle: ArrayBuffer(64) {},
        clientDataJSON: ArrayBuffer(118) {}
    }
    id: "VByF2w2hDXkVsevQFZdbOJdyCTGOrI1-sVEzOzsNnY0"//Base64URL 编码的凭证ID
    type: "public-key"
}//与create的返回结构基本一致,只是response里面变成了AuthenticatorAssertionResponse断言对象

其他返回值都和create一样,在此我们只看AuthenticatorAssertionResponse对象

response: Object:对于验证流程,认证会返回 AuthenticatorAssertionResponse

而不是 AuthenticatorAttestationResponse 对象,这个对象包含以下 4 个属性:

  • response.authenticatorData: ArrayBuffer:认证器信息,包含认证状态、签名计数等
  • response.clientDataJSON: ArrayBuffer:客户端数据,包含 origin(即凭证请求来源)、挑战等信息
  • response.signature: ArrayBuffer:被认证器签名的 authenticatorData + clientDataHashclientDataJSON 的 SHA-256 hash)
  • response.userHandle: ArrayBuffercreate() 创建凭证时的用户 ID user.id。许多 U2F 设备不支持这一特性,这一项将会是null

同样地,我们将 ArrayBuffer 们以合适的方式编码成字符串后就可以把 PublicKeyCredential 发送给依赖方以供验证了;

实现的详细步骤

在实际操作中 WebAuthn 相关的数据解码和密码计算比较复杂,在服务器端可以使用已有的第三方库来帮我们做这些。但有些语言的可能没有;后面在讲后端的解码细节,这里主要关系前端逻辑;

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

挑战需要在前端转换为 Uint8Array

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

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

别忘了依赖方信息,这个自己选择,一致就好;

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

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

一个create()请求参数例子:

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
}

然后就是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 列表(即 allowCredentials)。

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

当浏览器将数据传回后,依赖方需要做的事情就比之前要麻烦一些了。依赖方需要验证挑战,并用之前存储的公钥验证签名和签名计数。(后端的具体逻辑之后再细讲)

create返回值处理

可以去搜现成的第三方后端API解决解码问题,也可以自己了解步骤实现解码;

认证器返回的数据,以create返回的数据为例:

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

可以看到 Base64 编码后的 rawId 其实和 id 是一致的(不过 id 是 Base64URL 编码的)。而 type 则一定是 “public-key”。

主要关心的还是 respose 中的两项内容。

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

clientDataJSON
{
    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"
    
    
}

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

  • challenge 和之前发送的一致
  • origin 为创建凭证的页面的源(协议+主机名+端口号,并非 URL)
  • type 为 “webauthn.create”

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

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

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

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

attestationObject

attestationObject 的结构图示:

在这里插入图片描述

attestationObject 是 CBOR 编码后再被 Base64 编码的,因此我们需要先Base64解码再CBOR解码


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


解码之后:

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

键值的含义如下:

  • fmt:标明证明的格式。WebAuthn 预定义了几种格式,分别为:
    • none:没有证明
    • packed:为 WebAuthn 优化的证明格式
    • android-safetynet:Android 使用的格式
    • android-key:又是 Android 使用的格式
    • fido-u2f:FIDO U2F 认证器使用的格式
    • tpm:TPM 可信平台模块使用的格式
  • attStmt:证明对象,具体格式根据 fmt 的不同会有所不同
  • authData:包含公钥、签名计数等来自认证器的数据

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


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


response.attestationObject.authData

在这里插入图片描述

它的解码比较简单,要做的就是根据图示将它切开,然后适当地转换类型。其中各部分的含义如下:

  • rpIdHash:如其名,SHA-256 的 rpId,长度 32 字节
  • flags:标识认证状态,长度 1 字节。其中 8 位分别为:
    • ED:是否有扩展数据
    • AT:是否包含 attestedCredentialData。通常注册认证器时会包含 attestedCredentialData,而验证时不包含
    • 保留位
    • 保留位
    • 保留位
    • UV:用户是否已验证
    • 保留位
    • UP:用户是否在场
  • signCount:签名计数,长度 4 字节,签名计数不一定从 0 开始。
  • attestedCredentialData:包含公钥等凭据数据,变长。其结构如下:
    • aaguid:认证器的 AAGUID (Authenticator Attestation GUID),对于同一型号的认证器,它们的 AAGUID 是相同的,出于隐私考虑,如果不要求证明,认证器会以 0 填充 AAGUID。
    • credentialIdLength:标识 credentialId 的长度,长度 2 字节
    • credentialId:凭证 ID,和 rawId 一致,长度由 credentialIdLength 定义
    • credentialPublicKey:CBOR 编码的 COSE 格式的凭证公钥,变长
  • extensions:扩展数据,变长,本文不讨论

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

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

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

  1. 验证 rpIdHash 和预期的一致
  2. 按预期检查用户在场和用户验证状态
  3. 存储签名计数
  4. 存储公钥
get返回值处理
{
    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

response. clientDataJSON

首先是 clientDataJSON,和之前的解法一样,要验证的内容也一样,只是 type 从 “webauthn.create” 变成了 “webauthn.get”。

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

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

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

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

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

我们来看 signature,也就是签名。不过这个签名不是简单的对挑战的签名,具体算法如图所示:

在这里插入图片描述

计算签名时,认证器会将 authenticatorData 与 clientDataHash(也就是 clientDataJSON 的 SHA-256 Hash)拼接起来,并使用对应的私钥签名。依赖方应该使用对应的公钥将其解密,并验证内容是否是 authenticatorData 与 clientDataHash 的拼接。

验证认证器

验证认证器是非必要的;

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

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

再次说明,如果不是对安全性有极高的要求,向认证器索取证明以验证认证器是否可信是没有必要的。此外,验证认证器需要依赖方自行维护可信认证器列表,大大增加了维护的复杂性。且FIDO客户端会有提示,有可能会打扰到用户。

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

  • Basic Attestation (Basic) :基础的证明方式,认证器会使用认证器私钥签名
  • Self Attestation (Self) :自证明,认证器会使用凭证私钥签名
  • Attestation CA (AttCA) :认证器会使用多个证明私钥之一签名
  • Elliptic Curve based Direct Anonymous Attestation (ECDAA) :通过 DAA 实现的匿名证明

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

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

  • none:大部分情况下认证器会选择不进行证明,部分认证器会进行自证明。此时 AAGUID 会被 0 填充
  • indirect:认证器会试图通过替换 AAGUID 和选择合适的证明方式来进行匿名证明,具体方式由认证器选择
  • direct:认证器会提供最直接的证明信息

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

在这里插入图片描述

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

当证明不为空时,依赖方收到数据后根据 attestationObject.fmt 的不同,需要选择不同的验证方式来验证认证器的可信情况。

这里介绍一下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-----就能得到一个证书字符串了。之后,依赖方需要验证证书是否可信。
这里详细的情况和校验还需要看W3C文档;

当在 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=="
    ]
}
无用户名登录

认证器已经代替了密码,可是这还不够!在进行第一因素认证(即使用 WebAuthn 登录)时,我们还是需要输入用户名,然后才能进行身份认证。(在上面的步骤中,我们需要用户输入自己的用户名,以明确用户是要登录哪一个用户)

而实际上是可以做到不需要用户输入用户名,也就是绑定用户的认证器即可,用户的认证器代替了用户名;想的很美好,但是事实上:

大部分认证器为了实现无限对公私钥,会将私钥通过 Key Warp 等技术加密后包含在凭证 ID 中发送给依赖方,这样认证器本身就不用存储任何信息。不过,这就导致需要身份认证时,依赖方必须通过用户名找到对应的凭证 ID,将其发送给认证器以供其算出私钥。(因为认证器无法提供自己的公钥,要找到公钥需要依赖方传入凭证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”。

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 找到对应的用户,并使用对应的公钥验证签名。如果验证成功,则认为对应的用户身份认证成功,依赖方可以允许其登录。至此验证结束。

了解更多还需要直接去阅读W3C 规范。

  • 8
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值