Passkeys完全指南:下一代Web身份验证技术解析

本文详细介绍了Passkeys技术的完整实现流程,涵盖数据库设计、安全上下文、用户注册、登录流程、RP ID配置等关键技术要点,为开发者提供实用的技术指导。

Passkeys完全指南:下一代Web身份验证技术解析

开发平台要求

使用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)。建议为用户表添加新列来存储Passkey ID:

1
2
3
4
5
ALTER TABLE users ADD COLUMN passkey_id blob DEFAULT(randomblob(16));

UPDATE USERS SET passkey_id=hex(randomblob(CASE rowid WHEN 0
                                                      THEN 16
                                                      ELSE 16 END));

用户只能有一个密码,但可以有多个Passkeys。创建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();
    }
  })

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

 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
var createOptions : CredentialCreationOptions = {
  publicKey: {
    rp: {
      id: SEE_BELOW,
      name: "",
    },

    user: {
      id: Uint8Array.from(atob(userIdBase64), c => c.charCodeAt(0)),
      name: username,
      displayName: "",
    },

    excludeCredentials: existingCredentialIdsBase64.map(id => {
      return {
        type: "public-key",
        id: Uint8Array.from(atob(id), c => c.charCodeAt(0)),
      };
    }),

    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 IDs

RP代表"依赖方",RP ID是一个域名,每个Passkey在创建时都有一个固定的RP ID。每个Passkey操作都会断言一个RP ID,如果不匹配,则该Passkey对该操作不存在。

站点可以使用通过从左丢弃零个或多个标签直到遇到eTLD形成的任何RP ID。例如https://www.foo.co.uk可以断言www.foo.co.ukfoo.co.uk,但不能断言co.uk

记录Passkey

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

首先进行一些健全检查:

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') {
  // handle error
}

在服务器端,需要将行插入到passkeys表中。认证器数据是一种相当简单的二进制格式。偏移量32包含标志字节。检查第7位是否设置,然后提取:

  • 第4位作为backed_up的值
  • 偏移量53处的大端uint16作为凭证ID的长度
  • 偏移量55处的那么多字节作为id的值

处理注册异常

create()的Promise可能会导致异常。InvalidStateError是特殊的,意味着本地设备已存在Passkey。这不是错误,不会向用户显示任何错误。

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

使用自动完成登录

在用户名输入元素上,将webauthn添加到autocomplete属性:

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

在页面加载时运行函数来设置条件请求:

 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
var getOptions : CredentialRequestOptions = {
  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时会被签名。由于它们是大型随机值,服务器知道签名必须在其生成挑战后生成。

处理登录

如果用户选择Passkey,handleSignIn将被调用。将rawIdresponse.clientDataJSONresponse.authenticatorDataresponse.signature的ArrayBuffer发送到服务器。

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

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

要验证签名,需要构造签名数据并解析公钥。来自数据库的public_key_spki值以SubjectPublicKeyInfo格式存储。

还需要检查clientDataJSON

  • type成员为"webauthn.get"
  • challenge成员等于服务器为此登录给出的挑战的base64url编码
  • origin成员等于您站点的登录来源
  • crossOrigin成员(如果存在)为false

检查authenticatorData

  • 前32字节等于您使用的RP ID的SHA-256哈希
  • 偏移量32处的字节的第0位为1

移除密码

如果用户使用Passkeys登录,并且账户有备份的Passkey,可以考虑提示用户移除密码。

注册新的仅Passkey用户

对于新用户注册,如果功能检测满意,考虑使他们仅使用Passkey。

设置

可以为Passkeys提供两个按钮:

  1. 添加Passkey的按钮,使用上面的createOptions对象,但删除authenticatorAttachment以允许注册其他设备
  2. “重置Passkeys"按钮,会提示新的Passkey注册,删除所有其他Passkeys,并使用户的所有其他活动会话无效

测试向量

提供Python 3代码来检查断言签名:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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

# 测试代码实现签名验证
def sha256(m):
    digest = hashes.Hash(hashes.SHA256())
    digest.update(m)
    return digest.finalize()

signed_message = (from_hex(authenticator_data_hex) +
                  sha256(from_hex(client_data_json_hex)))

public_key = load_der_public_key(from_hex(public_key_spki_hex))
public_key.verify(from_hex(signature_hex),
                  signed_message,
                  ec.ECDSA(hashes.SHA256()))

提问渠道

StackOverflow是提问的合理场所,使用passkey标签。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计