The Copenhagen Book

WebAuthn

概述

Web身份验证(WebAuthn)标准允许用户使用设备进行身份验证,可以是PIN码或生物识别。私钥存储在用户设备中,公钥存储在你的应用程序中。应用程序可以通过验证签名来认证用户。由于凭证与用户设备绑定,并且不可能进行暴力破解,潜在攻击者需要物理访问设备。

WebAuthn通常有两种使用方式:通过密码钥匙或安全令牌。虽然没有严格定义,但密码钥匙通常指可以替代密码的凭证并存储在验证器中(驻留密钥)。另一方面,安全令牌则用作在密码验证后使用的第二因素。2FA的凭证通常加密并存储在依赖方的服务器中。这两种情况下,它们都是现有方法的更安全替代方案。

使用WebAuthn,应用程序还可以通过制造商验证设备。这需要声明,不在本页涵盖。

术语

  • 依赖方:你的应用程序。
  • 验证器:持有凭证的设备。
  • 挑战:随机生成的单次使用令牌,用于防止重放攻击。推荐的最小熵为16字节。
  • 用户存在:用户可访问设备。
  • 用户验证:用户通过PIN码或生物识别验证其身份。
  • 驻留密钥,可发现凭证:存储在验证器(用户设备和安全令牌)中的凭证。非驻留密钥加密并存储在依赖方服务器中(你的数据库)。

注册

在注册步骤中,验证器创建一个新凭证并返回其公钥。

在客户端,从服务器获取新挑战,并使用Web身份验证API创建新凭证。这会提示用户使用设备进行身份验证。像Safari这样的浏览器只允许在用户交互(按钮点击)后调用此方法。

const credential = await navigator.credentials.create({
    publicKey: {
        attestation: "none",
        rp: { name: "My app" },
        user: {
            id: crypto.getRandomValues(new Uint8Array(32)),
            name: username,
            displayName: name,
        },
        pubKeyCredParams: [
            {
                type: "public-key",
                // ECDSA with SHA-256
                alg: -7,
            },
        ],
        challenge,
        authenticatorSelection: {
            userVerification: "required",
            residentKey: "required",
            requireResidentKey: true,
        },
        excludeCredentials: [
            {
                id: new Uint8Array(/*...*/),
                type: "public-key",
            },
        ],
    },
});
if (!(credential instanceof PublicKeyCredential)) {
    throw new Error("Failed to create credential");
}
const response = credential.response;
if (!(response instanceof AuthenticatorAttestationResponse)) {
    throw new Error("Unexpected");
}

const clientDataJSON: ArrayBuffer = response.clientDataJSON;
const attestationObject: ArrayBuffer = response.attestationObject;
  • rp.name: 你的应用程序名称。
  • user.id: 用于验证器的随机用户ID。这可以不同于应用程序使用的实际用户ID。
  • user.name: 便于识别的用户标识符(用户名,电子邮件)。
  • user.displayName: 便于识别的显示名称(无需唯一)。
  • excludeCredentials: 用户凭证的列表,以避免重复凭证。

算法ID来自IANA COSE算法注册表。推荐使用ECDSA和SHA-256(ES256),因为它广泛支持。你也可以使用-257支持RSA(RS256),以兼容更多设备,但仅支持它的设备较少。

大多数情况下,attestation应设置为"none"。我们不需要验证验证器的真实性,并非所有验证器都支持这一操作。

对于密码钥匙,确保公钥是驻留密钥并要求用户验证。

const credential = await navigator.credentials.create({
    publicKey: {
        // ...
        authenticatorSelection: {
            userVerification: "required",
            residentKey: "required",
            requireResidentKey: true,
        },
    },
});

对于安全令牌,可以跳过用户验证,凭证不需要是驻留密钥。通过将authenticatorAttachment设置为cross-platform限制验证器为安全令牌。

const credential = await navigator.credentials.create({
    publicKey: {
        // ...
        authenticatorSelection: {
            userVerification: "discouraged",
            residentKey: "discouraged",
            requireResidentKey: false,
            authenticatorAttachment: "cross-platform",
        },
    },
});

客户端数据JSON和验证器数据会发送到服务器进行验证。发送二进制数据的简单方法是使用base64编码。另一个选择是使用CBOR等方案,将类似JSON的数据编码为二进制。

第一步是解析声称对象,该对象用CBOR编码。包括声明和验证器数据。你可以使用声明来验证用户的设备(如果需要)。如果在客户端将其设置为"none",则验证声明格式为none

var attestationObject AttestationObject

// 解析声称对象

if attestationObject.Fmt != "none" {
    return errors.New("invalid attestation statement format")
}

type AttestationObject  struct {
    Fmt                  string // "fmt"
    AttestationStatement AttestationStatement // "attStmt"
    AuthenticatorData    []byte // "authData"
}

type AttestationStatement struct {
    // 见规范
}

接下来解析验证器数据。

  • 字节0-31:依赖方ID哈希。
  • 字节32:标志:
    • 位0(最低有效位):用户存在。
    • 位2:用户验证。
    • 位6:包含凭证数据。
  • 字节33-36:签名计数器。
  • 可变字节:凭证数据(二进制)。

依赖方ID为域名,不包括协议或端口,验证器数据包括其SHA-256哈希。对于localhost,依赖方ID为localhost。检查用户存在标志和用户验证标志(如果需要)。每次使用凭证时签名计数器都会增加,可用于检测伪造设备。如果你的应用程序预期用于硬件安全令牌,其中凭证绑定到令牌上,你需要将计数器与凭证一起存储并确保其值大于之前的尝试。然而,由于密码钥匙可以在设备间共享,因此可以忽略。

然后,从凭证数据中提取凭证ID和公钥。

  • 字节0-15:验证器ID。
  • 字节16和17:凭证ID长度。
  • 可变字节:凭证ID。
  • 可变字节:COSE公钥。

公钥是CBOR编码的COSE密钥。

import (
    "bytes"
    "crypto/sha256"
    "encoding/binary"
    "encoding/json"
    "errors"
)
if len(authenticatorData) < 37 {
    return errors.New("invalid authenticator data")
}
rpIdHash := authenticatorData[0:32]
expectedRpIdHash := sha256.Sum256([]byte("example.com"))
if bytes.Equal(rpIdHash, expectedRpIdHash[:]) {
    return errors.New("invalid relying party ID")
}

// 检查“用户存在”标志。
if (authenticatorData[32] & 1) != 1 {
    return errors.New("user not present")
}
// 如果需要用户验证,检查“用户验证”标志。
if ((authenticatorData[32] >> 2) & 1) != 1 {
    return errors.New("user not verified")
}
if ((authenticatorData[32] >> 6) & 1) != 1 {
    return errors.New("missing credentials")
}

if (len(authenticatorData) < 55) {
    return errors.New("invalid authenticator data")
}
credentialIdSize:= binary.BigEndian.Uint16(authenticatorData[53 : 55])
if (len(authenticatorData) < 55 + credentialIdSize) {
    return errors.New("invalid authenticator data")
}
credentialId := authenticatorData[55 : 55+credentialIdSize]
coseKey := authenticatorData[55+credentialIdSize:]

// 解析COSE公钥

公钥的结构取决于使用的算法。下面是使用(x, y)作为公钥的ECDSA公钥。验证算法和曲线。

{
    1: 2 // EC2密钥类型
    3: -7 // ECDSA P-256与SHA-256的算法ID
    -1: 1 // P-256的曲线ID
    -2: 0x00...00 // x坐标的位串
    -3: 0x00...00 // y坐标的位串
}

接下来,验证JSON编码的客户端数据。源是包含协议和端口的应用程序域名。客户端数据中的挑战是base64url编码,不带填充。

import (
    "bytes"
    "crypto/sha256"
    "encoding/base64"
    "encoding/json"
    "errors"
)

var expectedChallenge []byte

// 验证挑战并从存储中删除。

var credentialId string

var clientData ClientData

// 解析JSON

if clientData.Type != "webauthn.create" {
    return errors.New("invalid type")
}
if !verifyChallenge(clientData.Challenge) {
    return errors.New("invalid challenge")
}
if clientData.Origin != "https://example.com" {
    return errors.New("invalid origin")
}

type ClientData struct {
    Type	  string // "type"
    Challenge string // "challenge"
    Origin	  string // "origin"
}

最后,用用户的公钥和凭证ID创建一个新用户。建议将COSE编码的公钥转换为更紧凑和标准的格式(ECDSA)。

认证

在认证步骤中,验证器使用私钥创建一个新签名。

在服务器上生成一个挑战并认证用户。

const credential = await navigator.credentials.get({
    publicKey: {
        challenge,
        userVerification: "required",
    },
});

if (!(credential instanceof PublicKeyCredential)) {
    throw new Error("Failed to create credential");
}
const response = credential.response;
if (!(response instanceof AuthenticatorAssertionResponse)) {
    throw new Error("Unexpected");
}

const clientDataJSON: ArrayBuffer = response.clientDataJSON;
const authenticatorData: ArrayBuffer = response.authenticatorData;
const signature: ArrayBuffer = response.signature;
const credentialId: ArrayBuffer = publicKeyCredential.rawId;

要实现使用安全令牌的2FA,传递用户凭证列表到allowCredentials以支持非驻留密钥。

const credential = await navigator.credentials.get({
    publicKey: {
        challenge,
        userVerification: "required",
        allowCredentials: [
            {
                id: new Uint8Array(/*...*/),
                type: "public-key",
            },
        ],
    },
});

客户端数据、验证器数据、签名和凭证ID会发送到服务器。首先验证挑战、验证器和客户端数据。这部分几乎与验证声明的步骤相同,只是客户端数据类型应为webauthn.get

if clientData.Type != "webauthn.get" {
    return errors.New("invalid type")
}

另一个不同之处在于验证器不包含凭证部分。

使用凭证ID获取凭证的公钥。**对于2FA,确保凭证属于认证用户。**跳过此检查将允许恶意行为者完全跳过2FA。签名是验证器数据和客户端数据JSON的SHA-256哈希。对于ECDSA,签名是ASN.1 DER编码的

import (
    "crypto/ecdsa"
    "crypto/sha256"
)

clientDataJSONHash := sha256.Sum256(clientDataJSON)
// 将验证器数据与客户端数据JSON的哈希连接。
data := append(authenticatorData, clientDataJSONHash[:]...)
hash := sha256.Sum256(data)
validSignature := ecdsa.VerifyASN1(publicKey, hash[:], signature)