12k
All articles

iOSおよびAndroidアプリにパスキーを導入する方法

SwiftとKotlinでiOS・Androidアプリにパスキーを追加。ドメイン関連付け、登録とログインの実装、サーバーでの安全な認証情報検証を解説します。

OpenReplay Team
OpenReplay Team
iOSおよびAndroidアプリにパスキーを導入する方法

パスキーは、1つのドメインに紐づく公開鍵と秘密鍵のペアです。アプリはプラットフォームのクレデンシャルAPIを通じてパスキーを作成し、サーバーは公開鍵のみを保持します。Face IDや指紋認証の役割は、デバイスに秘密鍵の使用を許可することだけです。

よくある失敗パターンがあります。ボタンの配線は済み、バックエンドもWebでは動作しているのに、実機では何も表示されません。システムシートも出ず、ログも残らず、多くの場合は汎用的なキャンセルエラーが返るだけです。

原因はほぼ例外なく、次の3つのいずれかです。アプリとドメインの関連付け、サーバーのリライングパーティ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 App Signingで配布するアプリでは、assetlinks.jsonにGoogleのアプリ署名鍵のフィンガープリントを記載する必要があります。アップロード鍵しか記載していないと、ローカルビルドではパスキーが動作するのに、ストアからインストールした場合には失敗します。
  • パスキーが有効かどうかを判断するのはサーバーです。セッションを作成する前に、使い捨てのチャレンジ、オリジン(Androidのandroid:apk-key-hash:オリジンを含む)、rpIdハッシュ、署名を検証します。
  • 同期されたパスキーはユーザーのAppleアカウントまたはGoogleアカウントに追従するため、自社サービスのアカウントセキュリティの一部がそれらのアカウントのセキュリティに依存することになります。パスキーに依存しないアカウント復旧手段を残しておきましょう。

パスキーは生体認証によるロック解除と同じものか?

パスキーは指紋によるロック解除ではありません。Face IDや指紋認証は、あくまでデバイスに秘密鍵の使用を許可するだけです。この秘密鍵は1つのドメイン用に作成されたもので、デバイスやユーザーの同期済みキーチェーンの外に出ることはありません。サーバーは対応する公開鍵を保持して署名を検証するだけで、生体情報を目にすることは一切ありません。

ローカルでの生体認証によるゲーティングは別の仕組みです。こちらはアプリが以前に保存したトークンをロック解除するもので、サーバーは何の暗号学的証明も得られません。それが目的であれば、React Nativeでの生体認証でセットアップ方法を解説しています。本ガイドではその内容は繰り返しません。

プラットフォームとの関連付け:エラーなしで失敗するステップ

サーバーが送信するリライングパーティID(rpId)は、両方の関連付けファイルをホストしているドメインと同一でなければなりません。OSは双方を個別にチェックします。アプリ側がドメインを列挙し、ドメイン側がアプリを列挙したファイルを公開します。どちらか一方でも誤っていると、パスキーのシートは表示されず、どちらのプラットフォームもその理由をアプリに伝えません。

iOS:webcredentialsエンタイトルメントとapple-app-site-association

webcredentialsサービスを指定して、ドメインをAssociated Domainsエンタイトルメントに追加します。

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

以下のファイルを拡張子なしでhttps://example.com/.well-known/apple-app-site-associationに配置します。アプリIDは、Team IDとBundle IDをドットで連結したものです。

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

AppleのAssociated Domainsのサポートに関するガイドによれば、このファイルは信頼された証明書を使ったHTTPSで配信する必要があり、リクエストがリダイレクトされてはなりません。このファイルには拡張子がないため、多くの静的ホスティングやオブジェクトストレージでは汎用的なバイナリのContent-Typeで配信されてしまいます。そのため、Content-Typeを明示的に設定してください。

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時間以内にファイルをリクエストし、インストール済みのデバイスはおよそ週に1回、更新されたコピーがないかを確認します。したがって、修正したファイルがインストール済みのアプリに反映されるまでには時間がかかります。まずCDNが再取得し、その後、各デバイスがインストール時、アップデート時、または次の週次チェック時にそれを取り込むからです。CDNが取得した時点でファイルが壊れていた場合、CDNはその壊れた結果もキャッシュします。開発中はwebcredentials:example.com?mode=developerを使用し、デバイスの「設定 › デベロッパ」で「Associated Domains Development」をオンにしてください。これにより、デバイスは自社ドメインから直接ファイルを取得するようになります。リリースビルドはCDNのコピーに依存するため、リリース前にこのサフィックスは必ず削除してください。

Android:get_login_credsを含むassetlinks.json

Androidでは、Credential Managerのパスキーを利用するにはDigital Asset Linksが必要です。rpIdのドメインで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 App Signingで配布している場合、Googleは配信前にアプリを再署名します。ストアからのインストールで有効となるフィンガープリントは、Play Consoleの「アプリの署名」ページに表示されるアプリ署名鍵のものです。ローカルのアップロード鍵しか記載していないことが、デバッグビルドではパスキーが動作するのに、ストアからインストールしたユーザーでは失敗する原因です。Playの耐量子ハイブリッド署名(quantum-ready hybrid signing)に登録しているアプリには複数のアプリ署名鍵があるため、そのページに表示されるすべてのSHA-256フィンガープリントを追加してください。余分なフィンガープリントを記載しても問題はありません。

事前チェック

チェック項目確認方法誤っている場合の症状
Content-TypeAASAのURLに対してcurl -sIを実行iOSでシートが表示されない
リダイレクトなしcurl -sIが301/302ではなく200を返す両プラットフォームでシートが表示されない
サブドメインの一致rpIdと完全に同じホストでファイルを配信Webでは動作するがアプリでは失敗する
Playのアプリ署名鍵Play Consoleの「アプリの署名」ページと照合デバッグでは動作するがストア版では失敗する
AppleのCDNコピー下記のCDN URLに問い合わせる古いファイルや壊れたファイルが使われ続ける

サブドメインは関連付けを継承しません。Digital Asset Linksの仕様では、Webサイトをスキーム、完全なホスト名、ポートで識別するため、www.example.comにあるファイルはrpIdがexample.comの場合には何の効果もありません。Appleのルールも同様で、サブドメインごとに個別のapple-app-site-associationファイルが必要です。

どちらのファイルも、ビルドをインストールせずに確認できます。1つ目のURLは、AppleのCDNが自社ドメインについて保持している内容を返します(Appleのエンジニアが診断に使用しているもので、公式にドキュメント化されたAPIではありません)。2つ目は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"

関連付けが壊れていても、クラッシュレポーターで捕捉できる例外はスローされません。セッションリプレイを使えば、このパターンを可視化できます。「パスキーでサインイン」をタップし、システムシートが表示されず、ユーザーがパスワード入力欄に切り替える、という流れです。そのうえで、これらのセッションをビルドや署名のバリアントごとに絞り込むことができます。

パスキーの実装方法:登録

ネイティブアプリでのパスキー登録は3つのパートで構成されます。サーバーが新しいチャレンジを含み、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の登録セレモニーに従って処理を行います。チャレンジ、オリジン、rpIdハッシュを検証し、クレデンシャルID、公開鍵、署名カウント、ユーザーハンドルを保存します。Androidアプリはandroid:apk-key-hash:<encoded SHA 256 fingerprint>というオリジンを送信するため、サーバーはこの値を完全一致で許可オリジンの1つとして受け入れる必要があります。署名証明書ごとに1つずつオリジンを追加してください。

サインイン:アサーションとサーバー側の検証

パスキーによるサインインも、登録と同じパターンに従います。サーバーがチャレンジを発行し、デバイスが秘密鍵でそれに署名し、サーバーが保存済みの公開鍵で署名を検証します。パスキーが有効かどうかをアプリが判断することはありません。

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. チャレンジが自身の発行したものと一致し、未使用であることを確認する。
  2. AndroidのすべてのAndroid apk-key-hashオリジンを含む許可リストとオリジンを照合する。
  3. rpIdHashがrpIdのSHA-256ハッシュと等しいことを確認する。
  4. ユーザー存在(User Present)フラグを必須とし、ポリシーで要求される場合はユーザー検証(User Verified)フラグも必須とする。
  5. 保存済みの公開鍵で署名を検証する。
  6. 署名カウントのルールを適用する。仕様では、保存済みの値または受信した値がゼロ以外の場合にのみカウンターを比較するため、カウンターがゼロであることを理由に拒否しないこと。

同期によってパスキーはアカウント復旧の問題になる

同期されたパスキーは、一度もアプリにサインインしたことのないデバイスにも現れます。Googleパスワードマネージャーに保存されたパスキーは、ユーザーのGoogleアカウントに追従して、同じアカウントでサインインしている他のAndroidデバイスでも利用可能になります。また、iCloudキーチェーンはユーザーのAppleデバイス間でパスキーを同期します。ユーザーは新しいスマートフォンを購入してアプリを開けば、すぐにサインインできます。

パスキーの同期は、アカウント復旧に関して3つの影響をもたらします。

  • スマートフォンを紛失しても、アカウントは失われない。 デバイスを紛失しても、ユーザーの他のデバイスではパスキーを引き続き利用できます。
  • プラットフォームのアカウントがセキュリティ境界の一部になる。 自社ドメイン用のパスキーの安全性は、2要素認証や復旧時のチェックを含め、ユーザーのAppleアカウントまたはGoogleアカウントに依存します。
  • 全員が同期しているわけではない。 キーチェーンの同期をオフにしているユーザー、サードパーティのプロバイダを使っているユーザー、デバイスにGoogleアカウントを設定していないユーザーは、唯一のクレデンシャルを失う可能性があります。

1つのアカウントに複数のパスキーを登録できるようにし、ユーザーがパスキーを確認・取り消しできる画面を用意し、メールやOTPによる復旧手段を残しておきましょう。新しいデバイスで、同期済みのパスキーがあるにもかかわらずサインインがパスワードにフォールバックする場合、その原因はユーザーが拒否したことではなく、たいていはrpIdまたは許可リストのバグです。

古いデバイスやパスキーを拒否するユーザー向けのフォールバック

リリース時には、パスワードまたはOTPによるサインインをパスキーと併用できるようにしておきましょう。AndroidのネイティブパスキーにはAndroid 9(APIレベル28)以上が必要で、サードパーティのパスキープロバイダの利用にはAndroid 14が必要です。また、どのデバイスを使っていてもプロンプトを拒否するユーザーは一定数存在します。NoCredentialExceptionとASAuthorizationError.canceledは、エラーとしてではなく、既存のログインフォームを表示する形で処理してください。

サインアップ時にパスキーの作成を強制するのではなく、パスワードでのサインインに成功した後にパスキーの作成を提案しましょう。データによって格下げできると判断できるまでは、従来のログイン手段を恒久的なものとして扱ってください。パスワードレスログインの仕組みでは、パスキーがマジックリンクやOTPよりも堅牢である理由を解説しています。React NativeやFlutterのアプリも同じルールに従います。どのブリッジライブラリを選んでも、内部ではこれらと同じネイティブAPIが呼び出されるため、関連付けファイルやサーバー側のチェックは変わりません。

まとめ

パスキーのバグのほとんどは、コードのバグではなく設定のバグです。ネイティブコードに着手する前に、rpIdを決め、その完全に同じドメインで両方の関連付けファイルをホストしてください。2つのcurlによる検証で確認し、Play App SigningのフィンガープリントとAndroidのオリジンをサーバーに追加しましょう。両方の検証でアプリが返されるようになれば、必要なクライアントコードは上記のSwiftとKotlinの呼び出しだけです。

よくある質問

ネイティブアプリでパスキーを使うには、最低でもどのiOSバージョンが必要ですか?

同期されたパスキーにはiOS 16以降が必要です。iOS 16は、AppleがiCloudキーチェーンに保存されるパスキーを導入したリリースです。iOS 15以前で組み込みのプラットフォーム認証器を使って作成したクレデンシャルはパスキーにはならず、作成したデバイス上にとどまります。これをアップグレードするには、同じユーザーハンドルで新しいクレデンシャルを登録してください。iOSが古いクレデンシャルを同期されたパスキーに置き換えます。Androidでは、最低要件はAndroid 9(APIレベル28)です。

Webサイトで作成したパスキーを使って、ユーザーはアプリにサインインできますか?

はい、WebサイトとアプリのrpIdが同じであれば可能です。パスキーはクライアントではなくrpIdに紐づくため、1つのクレデンシャルをブラウザと両方のネイティブアプリで使用できます。iOSのネイティブアプリは、https:// にrpIdを付けたオリジンを送信します。これはWebサイトが送信する値と同じです。Androidアプリはandroid:apk-key-hashオリジンを送信するため、サーバーはこれを別途許可リストに追加する必要があります。

AndroidエミュレータやiOSシミュレータでパスキーをテストできますか?

Androidでは可能です。APIレベル28以上の(素のAOSPではなく)Google Play搭載のエミュレータイメージを使用し、Googleアカウントでサインインして、画面ロックを設定してください。パスキーはGoogleパスワードマネージャーに保存されるためです。これらが揃っていないと、クレデンシャルの作成はアプリのバグのように見える例外で失敗します。iOSシミュレータは実機と異なる挙動を示すことがあるため、パスキーの最終テストは実機のiPhoneで行ってください。

コードのバグではなく、関連付けの不備を示すエラーはどれですか?

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.