深入浅出:Web开发者快速上手Passkeys技术指南

本文详细介绍了如何在Web开发中实现Passkeys身份验证,涵盖数据库设计、安全上下文、用户注册与登录流程、签名验证及测试向量,提供完整的技术实现方案。

Passkeys (2022年9月22日)

这是一份针对Web开发者的、带有主观色彩的Passkeys“快速入门”指南。希望它能广泛适用,但一种方案无法满足所有身份验证需求,本指南忽略了所有可选内容。因此,请将其视为一个工作示例,而非绝对真理。

它不使用任何WebAuthn库,仅假设您可以访问验证签名的函数。这可能不是最优方案——也许找一个好库是更好的主意——但Passkeys并不复杂,让人们了解其原理是合理的。

这篇文章可能需要随时间更新,因此不太适合放在博客中,未来我可能会移动它。但目前它就在这里。

开发Passkeys的平台包括:

  • iOS 16或macOS 13上的Safari。
  • Windows 22H2上的Chrome Canary(设置chrome://flags#webauthn-conditional-ui)。
  • macOS上的Chrome Canary(设置chrome://flags#webauthn-conditional-ui)。

数据库更改

每个用户都需要一个Passkey用户ID。用户ID用于标识账户,但不应包含任何个人身份信息(PII)。您的系统中可能已有用户ID,但应专门为Passkeys创建一个,以便更容易保持其无PII。在用户表中创建一个新列,并为其填充大型随机值。(以下使用SQLite语法,您需要针对其他数据库进行调整。)

1
2
3
4
5
6
7
/* SQLite在修改表时无法设置非常量DEFAULT,仅在创建表时可以,但这是我们希望编写的。 */
ALTER TABLE users ADD COLUMN passkey_id blob DEFAULT(randomblob(16));

/* CASE表达式导致函数非常量。 */
UPDATE USERS SET passkey_id=hex(randomblob(CASE rowid WHEN 0
                                                      THEN 16
                                                      ELSE 16 END));

一个用户只能有一个密码,但可以有多个Passkeys。因此为它们创建一个表:

1
2
3
4
5
6
CREATE TABLE passkeys (
  id BLOB PRIMARY KEY,
  username STRING NOT NULL,
  public_key_spki BLOB,
  backed_up BOOLEAN,
  FOREIGN KEY(username) REFERENCES users(username));

安全上下文

WebAuthn中的所有内容都必须在安全上下文中工作,因此如果您没有使用HTTPS,请先修复这个问题。

注册现有用户

当用户使用密码登录时,您可能希望提示他们在本地设备上创建Passkey,以便下次更轻松地登录。

首先,检查他们的设备是否有本地验证器,并且浏览器将支持Passkeys:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
if (!window.PublicKeyCredential ||
    !(PublicKeyCredential as any).isConditionalMediationAvailable) {
  return;
}

Promise.all([
    (PublicKeyCredential as any).isConditionalMediationAvailable(),
    PublicKeyCredential
      .isUserVerifyingPlatformAuthenticatorAvailable()])
  .then((values) => {
    if (values.every(x => x === true)) {
      promptUserToCreatePlatformCredential();
    }
  })

(这里的代码片段使用TypeScript。如果您需要,可以轻松将其转换为纯JavaScript。您可能会注意到几个地方TypeScript的DOM类型被覆盖,因为lib.dom.d.ts尚未跟上。希望这些情况会随时间消失。)

如果用户接受,请要求浏览器创建本地凭据:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
var createOptions : CredentialCreationOptions = {
  publicKey: {
    rp: {
      // RP ID。这需要一些思考。参见下面的评论。
      id: SEE_BELOW,
      // 此字段必须设置为某个值,但您可以忽略它。
      name: "",
    },

    user: {
      // `userIdBase64`是用户的Passkey ID,来自数据库,经过base64编码。
      id: Uint8Array.from(atob(userIdBase64), c => c.charCodeAt(0)),
      // `username`是用户的用户名。无论他们使用密码登录时会输入什么。
      name: username,
      // `displayName`可以是用户更人性化的名称,或者留空。
      displayName: "",
    },

    // 这列出了用户现有凭据的ID。即
    //   SELECT id FROM passkeys WHERE username = ?
    // 并将结果值列表(base64编码)作为existingCredentialIdsBase64提供。
    excludeCredentials: existingCredentialIdsBase64.map(id => {
      return {
        type: "public-key",
        id: Uint8Array.from(atob(id), c => c.charCodeAt(0)),
      };
    }),

    // 广告支持P-256 ECDSA和RSA PKCS#1v1.5的样板代码。支持这些密钥类型目前可以实现全面覆盖。
    pubKeyCredParams: [{
      type: "public-key",
      alg: -7
    }, {
      type: "public-key",
      alg: -257
    }],

    // 在注册期间未使用,除非在某些企业部署中。但不要在登录时这样做!
    challenge: new Uint8Array([0]),

    authenticatorSelection: {
      authenticatorAttachment: "platform",
      requireResidentKey: true,
    },

    // 三分钟。
    timeout: 180000,
  }
};

navigator.credentials.create(createOptions).then(
  handleCreation, handleCreationError);

RP ID

有两个级别的控制机制防止Passkeys在错误的网站上使用。您需要提前了解这一点,以防止后期陷入困境。

“RP”代表“依赖方”。您(网站)在身份验证术语中是“依赖方”。RP ID是一个域名,每个Passkey在创建时都有一个固定的RP ID。每个Passkey操作都会断言一个RP ID,如果Passkey的RP ID不匹配,则该操作中它不存在。

这防止了一个站点使用另一个站点的Passkeys。具有foo.com RP ID的Passkey不能在bar.com上使用,因为bar.com无法断言foo.com的RP ID。站点可以使用通过从左丢弃零个或多个标签直到遇到eTLD形成的任何RP ID。因此,假设您是https://www.foo.co.uk:您可以断言www.foo.co.uk(丢弃零个标签)、foo.co.uk(丢弃一个标签),但不能断言co.uk,因为那会遇到eTLD。如果您在请求中未设置RP ID,则默认为站点的完整域名。

我们的www.foo.co.uk示例可能很高兴地使用默认RP ID创建Passkeys,但后来决定将所有登录活动移动到隔离源https://accounts.foo.co.uk。但所有Passkeys都无法从该源使用!如果最初创建它们时使用foo.co.uk的RP ID,就可以允许这样做。

但您可能希望小心总是设置最通用的RP ID,因为那样usercontent.foo.co.uk也可以访问和覆盖它们。这引出了第二个控制机制。

正如您稍后将看到的,当使用Passkey登录时,浏览器会在签名数据中包含发出请求的来源。因此accounts.foo.co.uk将能够看到请求是由usercontent.foo.co.uk触发的并拒绝它,即使Passkey的RP ID允许usercontent.foo.co.uk使用它。但该机制无法对usercontent.foo.co.uk能够覆盖它们做任何事情。

因此,要么选择一个RP ID并将其放入上面的“SEE BELOW”占位符中,要么根本不包含rp.id字段并使用默认值。

记录Passkey

navigator.credentials.create的Promise成功解析时,您有一个新创建的Passkey!现在您必须确保服务器记录它。

Promise将产生一个PublicKeyCredential对象,其response字段是一个AuthenticatorAttestationResponse

首先,对浏览器中的一些数据进行健全性检查。由于在我们使用的配置中这些数据未签名,因此在客户端进行此检查是可以的。

1
2
3
4
5
6
7
const cdj = JSON.parse(
    new TextDecoder().decode(cred.response.clientDataJSON));
if (cdj.type != 'webauthn.create' ||
    (('crossOrigin' in cdj) && cdj.crossOrigin) ||
    cdj.origin != 'https://YOURSITEHERE') {
  // 处理错误
}

response上调用getAuthenticatorData()getPublicKey(),并将这些ArrayBuffer发送到服务器。

在服务器端,我们希望为这个用户向passkeys表中插入一行。验证器数据是一种相当简单的二进制格式。偏移量32包含标志字节。健全性检查位7是否设置,然后提取:

  • 位4作为backed_up的值。(即(authData[32] >> 4) & 1。)
  • 偏移量53处的大端uint16作为凭据ID的长度。
  • 从偏移量55开始的那么多字节作为id的值。

来自getPublicKey()ArrayBufferpublic_key_spki的值。这应该是插入行所需的所有值。

处理注册异常

create()的Promise也可能导致异常。InvalidStateError是特殊的,意味着本地设备已存在Passkey。这不是错误,并且不会向用户显示任何错误。他们会看到类似于注册Passkey的UI,但服务器不需要更新任何内容。

NotAllowedError意味着用户取消了操作。其他异常意味着发生了更意外的事情。

要测试异常是否是这些值之一,请执行类似以下操作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
function handleCreationError(e: Error) {
  if (e instanceof DOMException) {
    switch (e.name) {
      case 'InvalidStateError':
        console.log('InvalidStateError');
        return;

      case 'NotAllowedError':
        console.log('NotAllowedError');
        return;
    }
  }

  console.log(e);
}

(但显然不要在实际代码中只是将它们记录到控制台。)

使用自动完成登录

在您站点的某个地方,有用户名和密码输入。在用户名输入元素上,向autocomplete属性添加webauthn。因此如果您有:

1
<input type="text" name="username" autocomplete="username">

…那么将其更改为…

1
<input type="text" name="username" autocomplete="username webauthn">

Passkeys的自动完成与密码不同。对于后者,当用户从弹出窗口中选择用户名和密码时,输入字段会为他们填充。然后他们可以单击按钮提交表单并登录。对于Passkeys,不会填充任何字段,而是解析一个待处理的Promise。然后站点有责任导航/更新页面,以便用户登录。

该待处理Promise必须由站点在用户聚焦用户名字段并触发自动完成之前设置。(仅添加webauthn标签如果没有待处理Promise供浏览器解析,则不会执行任何操作。)要创建它,在页面加载时运行一个函数:

  • 进行功能检测,如果支持,
  • 启动“条件”WebAuthn请求以产生Promise,如果用户选择凭据,该Promise将被解析。

以下是进行功能检测的方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
if (!window.PublicKeyCredential ||
    !(PublicKeyCredential as any).isConditionalMediationAvailable) {
  return;
}

(PublicKeyCredential as any).isConditionalMediationAvailable()
  .then((result: boolean) => {
    if (!result) {
      return;
    }

    startConditionalRequest();
  });

然后,启动条件请求:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
var getOptions : CredentialRequestOptions = {
  // 这是关键选项,告诉浏览器不要显示模态UI。
  mediation: "conditional" as CredentialMediationRequirement,

  publicKey: {
    challenge: Uint8Array.from(atob(CHALLENGE_SEE_BELOW), c =>
                                 c.charCodeAt(0)),

    rpId: SAME_AS_YOU_USED_FOR_REGISTRATION,
  }
};

navigator.credentials.get(getOptions).then(
  handleSignIn, handleSignInError);

挑战

挑战是由服务器生成的大型随机值,在使用Passkey时进行签名。因为它们是大型随机值,服务器知道签名必须是在生成挑战之后生成的。这阻止了签名被捕获并多次使用的“重放”攻击。

挑战有点像CSRF令牌:它们应该是大型(16或32字节)、加密随机值,并存储在会话对象中。它们应该只使用一次:当收到登录尝试时,挑战应失效。未来的登录尝试必须使用新的挑战。

上面的代码片段有一个值CHALLENGE_SEE_BELOW,假定它是登录的base64编码挑战。登录页面可能通过XHR获取挑战,或者挑战可能注入到页面模板中。无论哪种方式,它必须在服务器生成!

处理登录

如果用户选择Passkey,则handleSignIn将被调用,并带有一个PublicKeyCredential对象,其response字段是一个AuthenticatorAssertionResponse

ArrayBuffer rawIdresponse.clientDataJSONresponse.authenticatorDataresponse.signature发送到服务器。

在服务器端,首先查找Passkey:

1
SELECT (username, public_key_spki, backed_up) FROM passkey WHERE id = ?

并使用rawId的值进行匹配。id列是主键,因此可以有零个或一个匹配行。如果有零行,则用户正在使用服务器不知道的Passkey登录——也许他们删除了它。这是一个错误,拒绝登录。

否则,服务器现在知道声称的用户名和公钥。要验证签名,您需要构造签名数据并解析公钥。数据库中的public_key_spki值以SubjectPublicKeyInfo格式存储,大多数语言都有某种方式摄入它们。以下是一些示例:

  • Java: java.security.spec.X509EncodedKeySpec
  • .NET: System.Security.Cryptography.ECDsa.ImportSubjectPublicKeyInfo
  • Go: crypto/x509.ParsePKIXPublicKey

您的语言的加密库应提供一个函数,该函数接受签名和一些签名数据,并告诉您该签名对于给定的公钥是否有效。对于签名,传入客户端发送的签名ArrayBuffer的值。对于签名数据,计算clientDataJSON的SHA-256哈希,并将其附加到authenticatorData的内容。如果签名无效,拒绝登录。

但还有一堆事情需要检查!将clientDataJSON解析为UTF-8 JSON并检查:

  • type成员是“webauthn.get”。
  • challenge成员等于服务器为此登录给出的挑战的base64url编码。
  • origin成员等于您站点的登录来源(例如,像“https://www.example.com”这样的字符串)。
  • crossOrigin成员(如果存在)为false。

还有更多!获取authenticatorData并检查:

  • 前32字节等于您使用的RP ID的SHA-256哈希。
  • 偏移量32处的字节的位零为一。即(authData[32] & 1) == 1。这是用户存在位,指示用户批准了签名。

如果所有这些检查都通过,则登录其Passkey的用户。即设置cookie并响应运行的JavaScript,以便它可以更新页面。

如果存储的backed_up值不等于(authData[32] >> 4) & 1,则在数据库中更新它。

移除密码

一旦用户使用Passkeys登录,太好了!但如果他们是从密码升级的,那么该密码仍然挂在账户上,没有任何用处,却创造风险。询问用户关于移除密码是好的。

如果账户有备份的Passkey,这样做是合理的。即如果

1
SELECT 1 FROM passkeys WHERE username = ? AND backed_up = TRUE

有结果。站点可以考虑在用户使用Passkey登录并注册了备份Passkey时,提示用户移除账户上的密码。

注册新的仅Passkey用户

对于新用户的注册,如果功能检测(来自注册用户部分)满意,考虑使他们仅使用Passkey。

当注册用户时,如果Passkey将是他们唯一的登录方法,您真的希望Passkey最终“在他们的口袋中”,即在他们的手机上。否则,他们可能在注册时使用的计算机上有一个Passkey,但如果它没有同步到他们的手机,那就不太方便。目前,恐怕没有很好的答案!希望几个月后,使用authenticatorSelection.authenticatorAttachment设置为cross-platform调用navigator.credentials.create()会做正确的事情。但在iOS 16上,它将排除平台验证器。

因此,目前,在所有平台上都这样做,除了iOS/iPadOS,其中authenticatorAttachment应继续为platform

(当答案更简单时,我会尝试更新这一部分!)

设置

如果您在任何站点上使用过安全密钥,您会注意到它们倾向于在账户设置中列出注册的安全密钥,让用户命名每个密钥,显示最后使用时间,并允许单独移除它们。如果您喜欢,也可以对Passkeys这样做,但这相当复杂。相反,我认为您可以只有两个按钮:

首先,一个添加Passkey的按钮,使用上面的createOptions对象,但删除authenticatorAttachment以允许注册其他设备。

其次,一个“重置Passkeys”按钮(像“重置密码”按钮)。它将提示新的Passkey注册,删除所有其他Passkeys,并使用户的所有其他活动会话失效。

测试向量

连接到您语言的加密库是其中较棘手的部分之一。为了帮助,这里有一些测试向量,以Python 3代码的形式提供,检查断言签名,为您提供一个检查的基础真相。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import codecs

from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.serialization import (
  load_der_public_key)

# 这是公钥的SPKI格式,从注册时的`getPublicKey`调用获得。
public_key_spki_hex = '''
3059301306072a8648ce3d020106082a8648ce3d03010703420004dfacc605c6
e1192f4ab89671edff7dff80c8d5e2d4d44fa284b8d1453fe34ccc5742e48286
d39ec681f46e3f38fe127ce27c30941252430bd373b0a12b3e94c8
'''

# 这是断言时`clientDataJSON`字段的内容。这是UTF-8 JSON,您还需要以多种方式验证;参见正文。
client_data_json_hex = '''
7b2274797065223a22776562617574686e2e676574222c226368616c6c656e67
65223a22594934476c4170525f6653654b4d455a444e36326d74624a73345878
47316e6f757642445a483664436141222c226f726967696
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计