12k
All articles

如何为 iOS 和 Android 应用添加通行密钥(Passkey)

使用Swift和Kotlin为iOS及Android应用添加通行密钥,配置域名关联,实现注册与登录,并在服务器端安全验证凭证。

OpenReplay Team
OpenReplay Team
如何为 iOS 和 Android 应用添加通行密钥(Passkey)

通行密钥(passkey)是一个限定于单一域名的公钥/私钥对。应用通过平台凭据 API 创建它,服务器只保存公钥,而 Face ID 或指纹的作用仅仅是允许设备使用私钥。

一种常见的失败情形是:按钮已经接好,后端在 Web 端运行正常,但在真机上什么都没有出现。没有系统弹窗,没有日志,通常只有一个笼统的取消错误。

原因几乎总是以下三者之一:应用与域名的关联配置、服务器的依赖方 ID(relying party ID)与该域名不匹配,或者服务器从未完成的验证逻辑。本指南分别讲解 iOS(Swift)和 Android(Kotlin)上的各个环节,并给出每一步所需的服务端工作。本文假设你已经决定采用通行密钥(密码与通行密钥的取舍已在其他文章中讨论)。

核心要点

  • 只有当应用声明了某个域名,且该域名发布了一个指明该应用的文件时,应用才能为该域名创建或使用通行密钥:iOS 上是包含 webcredentials 的 apple-app-site-association,Android 上是包含 delegate_permission/common.get_login_creds 的 assetlinks.json。
  • 服务器下发的依赖方 ID 必须是同时托管这两个关联文件的域名,日后一旦更改,所有现有通行密钥都将无法使用。
  • 通过 Play 应用签名(Play App Signing)分发的应用,必须在 assetlinks.json 中列出 Google 的应用签名密钥指纹。如果只列出上传密钥,通行密钥在本地构建中可以正常工作,但在商店安装的版本中会失败。
  • 通行密钥是否有效由服务器决定。服务器在创建会话之前,会校验一次性 challenge、origin(包括 Android 的 android:apk-key-hash: origin)、rpId 哈希以及签名。
  • 同步的通行密钥跟随用户的 Apple 或 Google 账号,因此你的账号安全在一定程度上依赖于这些平台账号的安全。请保留一条不依赖通行密钥的账号恢复途径。

通行密钥等同于生物识别解锁吗?

通行密钥并不是指纹解锁。Face ID 或指纹只是允许设备使用一个私钥,该私钥为某个域名创建,且永远不会离开设备或用户同步的钥匙串。服务器持有对应的公钥并校验签名,它从不接触任何生物识别数据。

本地生物识别门控是另一种机制。在那种方式下,应用解锁的是之前存储的令牌,服务器并没有任何密码学证明。如果你需要的是这种方案,可以参考 React Native 中的生物识别认证,本指南不再赘述。

平台关联:静默失败的那一步

服务器下发的依赖方 ID(rpId)必须与托管两个关联文件的域名完全一致。操作系统会独立校验双方:应用声明域名,域名发布一个列出该应用的文件。任何一方出错,通行密钥弹窗都不会出现,而且两个平台都不会告诉应用原因。

iOS:webcredentials 权限与 apple-app-site-association

在 Associated Domains 权限中使用 webcredentials 服务添加域名:

<key>com.apple.developer.associated-domains</key>
<array>
    <string>webcredentials:example.com</string>
</array>

在 https://example.com/.well-known/apple-app-site-association 提供以下文件(无扩展名)。App ID 由 Team ID 和 Bundle ID 以点号连接而成:

{
  "webcredentials": {
    "apps": ["ABCDE12345.com.example.shop"]
  }
}

Apple 的支持关联域名指南规定,该文件必须通过 HTTPS 并使用受信任的证书提供,且请求不得发生重定向。由于该文件没有扩展名,许多静态托管服务和对象存储会以通用的二进制内容类型返回它,因此需要显式设置类型:

location = /.well-known/apple-app-site-association {
    default_type application/json;
}
curl -sI https://example.com/.well-known/apple-app-site-association | grep -i content-type
# expect: content-type: application/json

设备并不会直接从你的服务器获取此文件,而是读取由 Apple CDN 抓取并缓存的副本。根据 Apple 文档,CDN 会在大约 24 小时内请求该文件,已安装应用的设备大约每周检查一次更新。因此,修正后的文件需要一段时间才能生效:CDN 首先要重新抓取,然后每台设备在安装、更新或下一次每周检查时才会获取新版本。如果 CDN 抓取时文件是损坏的,CDN 也会把这个损坏的结果缓存下来。开发阶段可以使用 webcredentials:example.com?mode=developer,并在设备的”设置 › 开发者”中开启 Associated Domains Development,此时设备会直接从你的域名获取文件。发布前务必移除该后缀,因为正式构建依赖的是 CDN 副本。

Android:包含 get_login_creds 的 assetlinks.json

在 Android 上,Credential Manager 通行密钥需要在 rpId 域名上配置 Digital Asset Links,文件位于 https://example.com/.well-known/assetlinks.json:

[
  {
    "relation": ["delegate_permission/common.get_login_creds"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.shop",
      "sha256_cert_fingerprints": [
        "<PLAY_APP_SIGNING_KEY_SHA256>",
        "<UPLOAD_KEY_SHA256>"
      ]
    }
  }
]

列出你用于签名的所有密钥库的指纹:

keytool -list -v -keystore release.keystore -alias release | grep SHA256

如果应用使用 Play 应用签名发布,Google 会在分发前对其重新签名。对商店安装版本起作用的指纹,是 Play 管理中心”应用签名”页面中显示的应用签名密钥。只列出本地上传密钥,正是通行密钥在调试构建中可用、却对商店安装用户失效的原因。加入 Play 抗量子混合签名的应用拥有不止一个应用签名密钥,因此需要添加该页面显示的每一个 SHA-256 指纹。多列指纹不会有任何害处。

上线前检查

检查项验证方法出错时的表现
Content-Type对 AASA URL 执行 curl -sIiOS 上不弹出系统界面
无重定向curl -sI 返回 200,而非 301/302两个平台都不弹出系统界面
子域名匹配文件托管在与 rpId 完全一致的主机上Web 端可用,应用内失败
Play 签名密钥与 Play 管理中心的”应用签名”页面比对调试构建可用,商店版本失败
Apple CDN 副本查询下方的 CDN URL仍在使用旧的或损坏的文件

子域名不会继承关联关系。Digital Asset Links 规范通过协议、精确主机名和端口来标识网站,因此放在 www.example.com 上的文件对 rpId 为 example.com 的情况毫无作用。Apple 的规则相同:每个子域名都需要自己的 apple-app-site-association 文件。

这两个文件都可以在不安装构建的情况下进行检查。第一个 URL 返回 Apple CDN 为你的域名缓存的内容(Apple 工程师用它进行诊断,但它并非公开文档化的 API)。第二个调用 Google 的 statements.list 接口:

curl https://app-site-association.cdn-apple.com/a/v1/example.com

curl "https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://example.com&relation=delegate_permission/common.get_login_creds"

关联配置出错时不会抛出任何能被崩溃上报工具捕获的异常。会话回放可以让这一模式变得可见:用户点击”使用通行密钥登录”,没有出现系统弹窗,随后转而去填写密码框。之后你可以按构建版本和签名变体筛选这些会话。

如何实现通行密钥:注册

原生应用中的通行密钥注册分为三个部分:服务器生成包含全新 challenge 的选项,并将 rp.id 设为已关联的域名;原生 API 创建凭据;服务器验证结果,并存储公钥和凭据 ID。服务器的选项如下所示:

{
  "challenge": "<base64url, random, single-use>",
  "rp": { "id": "example.com", "name": "Example Shop" },
  "user": { "id": "<base64url user handle>", "name": "ada@example.com", "displayName": "Ada" },
  "pubKeyCredParams": [{ "type": "public-key", "alg": -7 }],
  "authenticatorSelection": { "residentKey": "required", "userVerification": "preferred" },
  "attestation": "none",
  "excludeCredentials": []
}

在 iOS 上,由 ASAuthorizationPlatformPublicKeyCredentialProvider 构建请求:

let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: "example.com")
let request = provider.createCredentialRegistrationRequest(
    challenge: challenge, name: "ada@example.com", userID: userHandle)
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.delegate = self
controller.presentationContextProvider = self
controller.performRequests()

// In the delegate:
if let reg = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration {
    api.finishRegistration(id: reg.credentialID, clientData: reg.rawClientDataJSON,
                           attestation: reg.rawAttestationObject)
}

在 Android 上,添加 androidx.credentials:credentials 和 androidx.credentials:credentials-play-services-auth 依赖。根据 FIDO2 迁移指南,在 Android 13 及更低版本上必须使用后者。最新版本号请参阅版本说明。然后将服务器返回的 JSON 直接传给 CreatePublicKeyCredentialRequest:

val credentialManager = CredentialManager.create(context)
val result = credentialManager.createCredential(
    context = activity,
    request = CreatePublicKeyCredentialRequest(requestJson = optionsJson)
) as CreatePublicKeyCredentialResponse
api.finishRegistration(result.registrationResponseJson)

随后,服务器按照 WebAuthn 注册流程进行处理:校验 challenge、origin 和 rpId 哈希,然后存储凭据 ID、公钥、签名计数(sign count)和用户句柄(user handle)。Android 应用发送的 origin 格式为 android:apk-key-hash:<encoded SHA 256 fingerprint>,服务器必须将这个确切的值加入允许的 origin 列表。每个签名证书对应添加一个 origin。

登录:断言与服务端验证

通行密钥登录与注册遵循相同的模式:服务器下发 challenge,设备用私钥对其签名,服务器使用存储的公钥验证签名。通行密钥是否有效,从来不由应用决定。

let request = provider.createCredentialAssertionRequest(challenge: challenge)
// Delegate receives ASAuthorizationPlatformPublicKeyCredentialAssertion:
// credentialID, rawClientDataJSON, rawAuthenticatorData, signature, userID
val request = GetCredentialRequest(listOf(GetPublicKeyCredentialOption(requestJson = optionsJson)))
val result = credentialManager.getCredential(context = activity, request = request)
api.finishSignIn((result.credential as PublicKeyCredential).authenticationResponseJson)

按照断言验证步骤,服务器需要:

  1. 确认 challenge 与自己签发的某个 challenge 一致,且尚未被使用过。
  2. 根据允许列表校验 origin,包括所有 Android apk-key-hash origin。
  3. 校验 rpIdHash 是否等于 rpId 的 SHA-256 哈希值。
  4. 要求设置用户在场(user-present)标志;如果策略要求,还需设置用户已验证(user-verified)标志。
  5. 使用存储的公钥验证签名。
  6. 应用签名计数规则。规范仅在存储值或接收值非零时才比较计数器,因此不要拒绝计数为零的情况。

同步让通行密钥成为账号恢复问题

同步的通行密钥会出现在从未登录过你应用的设备上。保存在 Google 密码管理工具中的通行密钥会跟随用户的 Google 账号同步到其他已登录的 Android 设备,iCloud 钥匙串也会在用户的 Apple 设备之间同步通行密钥。用户换了新手机,打开你的应用就能直接登录。

通行密钥同步对账号恢复有三方面影响:

  • 丢的是手机,不是账号。 设备丢失时,通行密钥在用户的其他设备上依然可用。
  • 平台账号如今已成为你安全边界的一部分。 你域名下通行密钥的安全性,取决于用户 Apple 或 Google 账号的安全性,包括其双重认证和账号恢复校验。
  • 并非所有人都开启同步。 关闭钥匙串同步、使用第三方凭据提供方,或设备上没有登录 Google 账号的用户,仍有可能丢失唯一的凭据。

允许每个账号拥有多个通行密钥,提供一个可供用户查看和撤销通行密钥的页面,并保留邮箱或 OTP 恢复途径。在新设备上,如果用户明明有同步的通行密钥,登录却回退到了密码方式,这通常说明存在 rpId 或允许列表方面的 bug,而不是用户主动拒绝。

为旧设备和拒绝使用的用户提供回退方案

上线初期,请在通行密钥之外保留密码或 OTP 登录。Android 上的原生通行密钥需要 Android 9(API 级别 28)或更高版本,而使用第三方通行密钥提供方则需要 Android 14。无论使用什么设备,总有一些用户会拒绝该提示。处理 NoCredentialException 和 ASAuthorizationError.canceled 时,应显示现有的登录表单,而不是报错。

在用户成功通过密码登录后再提供创建通行密钥的选项,而不是在注册时强制要求。在数据表明可以降级旧登录方式之前,应将其视为长期保留的方案。无密码登录的工作原理一文解释了为什么通行密钥比魔法链接和 OTP 更可靠。React Native 和 Flutter 应用遵循同样的规则:无论选择哪个桥接库,最终调用的都是这些原生 API,因此关联文件和服务端校验都不会改变。

总结

大多数通行密钥 bug 都是配置问题,而非代码问题。在编写任何原生代码之前,先确定 rpId,并将两个关联文件托管在与之完全一致的域名上。使用上述两个 curl 验证命令进行检查,并在服务器上添加 Play 应用签名指纹和 Android origin。一旦两个验证命令都返回了你的应用,上面的 Swift 和 Kotlin 调用就是你所需的全部客户端代码。

常见问题

原生应用使用通行密钥最低需要哪个 iOS 版本?

同步通行密钥需要 iOS 16 或更高版本,Apple 正是在这一版本中引入了存储于 iCloud 钥匙串的通行密钥。在 iOS 15 或更早版本上使用内置平台认证器创建的凭据永远不会变成通行密钥,只会保留在创建它的设备上。如需升级,使用相同的用户句柄注册一个新凭据,iOS 会用同步通行密钥替换旧凭据。在 Android 上,最低要求是 Android 9(API 级别 28)。

用户能否使用在我网站上创建的通行密钥登录应用?

可以,只要网站和应用使用相同的 rpId。通行密钥限定于 rpId,而非客户端,因此同一个凭据可以在浏览器和两个原生应用中通用。iOS 原生应用上报的 origin 是 https:// 加上 rpId,与网站发送的值相同。Android 应用上报的是 android:apk-key-hash origin,服务器必须将其单独加入允许列表。

能否在 Android 模拟器和 iOS 模拟器上测试通行密钥?

Android 上可以。使用 API 级别 28 或更高、带有 Google Play 的模拟器镜像(而非纯 AOSP 镜像),登录 Google 账号并设置屏幕锁定,因为通行密钥由 Google 密码管理工具存储。缺少这些条件时,凭据创建会抛出一个看起来像应用 bug 的异常。iOS 模拟器的行为可能与真机不同,因此通行密钥的最终测试请在实体 iPhone 上进行。

哪些错误表明是关联配置出错,而非代码 bug?

在 Android 上,注册时出现 CreatePublicKeyCredentialDomException 通常意味着 assetlinks.json 缺失或无法访问、包名或指纹错误,或者托管文件的主机与 rpId 不匹配。在 iOS 上,关联出错通常表现为 ASAuthorizationError 错误码 1001,与用户取消时产生的错误码相同。Android 上的 GetCredentialCancellationException 表示用户关闭了弹窗,无需显示错误信息。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.