Passkeys in iCloud Keychainの実装紹介

こんにちは、Kyashモバイルチームに所属しているiOSエンジニアの @nekowen です。
8月上旬にマネーフォワードさん、エブリーさん、Kyashの3社合同でWWDCの勉強会を開催しました。

moneyforward.connpass.com

自分はPasskeys in iCloud KeychainについてLT形式で発表しました。
その内容とトークの中では説明しきれなかった細かいコード部分について紹介したいと思います。

WebAuthnについて

Passkeys in iCloud KeychainはWebAuthnをベースにしているので、先にWebAuthnについて軽く説明します。

WebAuthnはFIDO2をWebアプリケーション上で利用可能にするセキュアな認証APIです。 特徴として以下が挙げられます。

  • 公開鍵暗号化方式を利用しており、パスワードやSMSコードが不要(=パスワードレス)
    • パスワードを管理する必要がないので、アカウント登録・ログインのユーザー体験が向上する
  • origin検証などがブラウザ(OS)レベルで行われるので、フィッシングに対して耐性がある

ではWebAuthnはどのような仕組みでしょうか?大まかな仕組みとして、登録と認証(ここではわかりやすいようにアカウント登録とログインと表記します)の2パターンで説明します。

まずアカウント登録の場合は、デバイスまたはセキュリティキーを使用してキーペアを生成します。そこから公開鍵や認証器の情報を含むオブジェクトをサーバーに送信、検証が成功すれば登録が完了します。

ログインの場合は、アカウント登録の時に生成した秘密鍵を使って署名を行います。署名データや認証器の情報をサーバーに送信して、サーバー側で署名の検証が成功すれば有効性が確認できるのでログイン完了、という流れです。

f:id:nekowen:20211008174209p:plain
WWDC 2021 Move beyond passwordsより引用

公開鍵を使った認証方式はとても強力ですが、大きな課題としてこれらの鍵はデバイスまたはセキュリティキーに強く紐づいており、紛失或いはなんらかの理由で利用できなくなるとリカバリーが効かないというものがあります。

加えて複数端末で同一のアカウントを利用したい場合も不便です。従来のパスワード認証の場合はIDとパスワードさえ知っていれば新しいデバイスで打ち込んでログインができましたが、WebAuthnを利用する場合はそうはいきません。デバイスにもサーバーにも鍵がないので、なんらかの手段でログインして公開鍵を登録する必要があります。

Passkeys in iCloud Keychainの登場

そんな中、AppleはWWDC2021のMove beyond passwordsでWebAuthnをベースにした新しい技術Passkeys in iCloud Keychainを発表しました。

f:id:nekowen:20211008174604p:plain
WWDC 2021 Move beyond passwordsより引用

iCloud Keychainはパスワードを含む機密情報をセキュアにiCloud上で管理でき、アカウントと紐づく他のAppleバイスと迅速な同期が可能です。

Passkeys(合鍵)と組み合わせることで、いつでも鍵が復旧できるので紛失に対応でき、且つ他のデバイスに同期することで同じアカウントで手軽にサインインが可能になります。
つまり、先ほどのWebAuthnが抱えていた課題をPasskeys in iCloud Keychainは丸っと解決できることになります。

さらに、Appleは今回これらの機能をネイティブアプリにも開放しました。WebAuthnはiOS 13.3のSafariでサポートされていましたが、今回からAPIとしてアプリに同等の機能を取り込めるようになります。

f:id:nekowen:20211011175829p:plain
WWDC 2021 Move beyond passwordsより引用

デメリットとしては現時点の利用範囲はAppleバイス製品のみと限られている点で、他のプラットフォームでの利用ができません。 また今回のリリースでは開発者プレビューという扱いのため、プロダクション投入ができず、試験的利用のみとなります。今後の流れに期待しましょう。

では、実際にどのようにPasskeys in iCloud Keychainを実装していくのか説明していきます。

デモサーバーの準備(Optional)

Passkeys APIの動作を確認するだけであればクライアントのみでも完結しますが、今回は自分がWebAuthnの仕組みを把握したいこともあり以下のデモを利用しました。

github.com

事前準備

上記でも述べたように、Passkeys in iCloud KeychainはiOS 15では開発者プレビュー機能のため、機能の有効化が必要です。

iOSの設定画面 -> デベロッパを開き、Syncing Platform Authenticator をオンにします。これで設定は完了です。

ちなみに生成したパスキーは Delete All Platform Credentials からまとめて削除できます。テストアカウントを作成した後に使うと便利です。

f:id:nekowen:20211008174700p:plain:h640
Syncing Platform Authenticatorをオンにする

次にAssociated Domainsを有効化します。これにより、Webサイトとアプリ相互にWebAuthnの資格情報を共有することが可能になります。

この対応はサーバー側の作業も必要です。アプリと関連するドメインであることを証明するために、ドメイン配下に対応するアプリの識別子を記載したJSONを配置する必要があります。

今回はWebの資格情報を利用するので、service名はwebcredentialsになります。そのためファイルには以下のように書き込みます。appsの識別子は適宜読み替えてください。

{
   "webcredentials": {
      "apps": [ "XXXXX.com.example.passkeys-test-app" ]
   }
}

このJSONを以下のパスに沿って外部からアクセスできるように配置します。

https://{サービスのドメイン}/.well-known/apple-app-site-association

次に、XcodeのSigning & CapabilitiesからAssociated Domainsを追加します。

Domainsには webcredentials:{サービスのドメイン} を指定します。

f:id:nekowen:20211008174938p:plain
Associated Domainsを追加する

これで事前準備は完了です。

アカウント登録

早速実装を進めていきます。まずはアカウント登録の実装から入ります。

最初にサーバーに登録したいユーザー名を渡し、チャレンジと呼ばれるランダムな文字列と、仮のUser IDを受け取ります。
このチャレンジはリプレイ攻撃を防ぐために必要です。またW3Cによると、ランダムな文字列は少なくとも16バイト以上の長さが推奨されています。*1

    // サーバーからChallengeとUser IDを取得する
    let res = try await requestRegisterChallenge(userName: userName)

次にPlatform Authenticatorを利用するために、 ASAuthorizationPlatformPublicKeyCredentialProvider インスタンスを生成します。その際にRelying Partyが必要になりますが、これは一般にサービスのドメイン名を指定します。

登録の場合、createCredentialRegistrationRequest を呼び出します。引数には最初にサーバーから取得したチャレンジとユーザー名、User IDを指定します。

    // プラットフォームキーのリクエストを生成する。その際にRelying Party(ここではサービスのドメイン名)を指定する
    let credentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: domain)
    // 登録の場合は createCredentialRegistrationRequest を呼ぶ
    let registrationRequest = credentialProvider.createCredentialRegistrationRequest(
        challenge: res.publicKey.challenge, // Data型
        name: userName,
        userID: res.publicKey.user.id // Data型
    )

ここまでで必要なデータの準備は完了しました。次は、画面下から出てくる認証シートを表示します。

Sign in with Appleを実装したことがある方にはお馴染みかもしれませんが、シートの表示にはASAuthorizationController を使用します。 引数のauthorizationRequestsに createCredentialRegistrationRequest で返ってきた登録リクエストを指定します。

リクエストの結果を受け取るために、delegateを指定します。指定するオブジェクトは ASAuthorizationControllerDelegate に準拠している必要があります。

それができたら、最後に performRequests を呼び出します。シートが出てきたら成功です。

    // iOS標準のシートを表示するためにASAuthorizationControllerに先程の登録リクエストを指定
    let authController = ASAuthorizationController(authorizationRequests: [registrationRequest])
    authController.delegate = self
    authController.presentationContextProvider = self
    authController.performRequests()

認証結果のハンドリング

Touch ID/Face IDによる認証と鍵の生成が行われて成功すると、authorizationController(controller:didCompleteWithAuthorization:)にコールバックが呼ばれます。

func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
    // このメソッドでは認証の要求に対する結果の情報が返ってきます
    switch authorization.credential {
    // アカウント登録の場合
    case let credentialRegistration as ASAuthorizationPlatformPublicKeyCredentialRegistration:

        let attestationObject = credentialRegistration.rawAttestationObject
        let clientDataJSON = credentialRegistration.rawClientDataJSON
        let credentialId = credentialRegistration.credentialID
        
        Task {
            try await requestRegisterFinish(
                userName: userName,
                credentialId: credentialId,
                attestationObject: attestationObject,
                clientDataJSON: clientDataJSON
            )
        }
    ...
    }
}

ASAuthorization.credential に登録時の情報が返ってきますが、このプロパティは ASAuthorizationCredential プロトコルで返却するため、ASAuthorizationPlatformPublicKeyCredentialRegistration へキャストが必要です。 また登録以外のリクエストをASAuthorizationControllerに指定した場合もこのプロパティで受け取ることになります。

キャストができたら、データを取り出してサーバーへ送り検証を行います。実装により微妙に異なるのですが、検証には以下の3つの情報が必要です。

attestationObjectバイスやセキュリティキーの信頼性や情報、公開鍵を含むバイナリオブジェクトです。 サーバーサイドではこれらの情報を元に、正規の方法で登録されているか、証明書が正当かどうか検証を行います。

どういった情報が含まれているのかはW3Cに詳しく記載があります。

clientDataJSON チャレンジやoriginが含まれているJSONオブジェクトです。 サーバーサイドは最初にクライアントに渡したデータと一致するかどうか検証を行います。

credentialID 登録時に紐づく一意の認証IDです。 サーバーサイドではこのIDが既に他のユーザーによって登録されていないか確認を行います。

ログイン

アカウント登録実装を行ったので、次はログインを実装します。実装上は登録の場合とほぼ変わりません。

func signIn(userName: String) async throws {
    let challengeRes = try await requestLoginChallenge(userName: userName)
    let credentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: domain)

    // ログインの場合はcreateCredentialAssertionRequestを呼ぶ
    let assertionRequest = credentialProvider.createCredentialAssertionRequest(challenge: challengeRes.publicKey.challenge)
    
    let authController = ASAuthorizationController(authorizationRequests: [assertionRequest])
    authController.delegate = self
    authController.presentationContextProvider = self
    authController.performRequests()
}

唯一異なる点は、リクエストの生成メソッドが createCredentialAssertionRequest になっています。

performRequestsを呼び出すと、ログイン用のシートが表示されます

func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
    switch authorization.credential {
    // ログインの場合
    case let credentialAssertion as ASAuthorizationPlatformPublicKeyCredentialAssertion:

        let credentialId = credentialAssertion.credentialID
        let clientDataJSON = credentialAssertion.rawClientDataJSON
        guard let signature = credentialAssertion.signature,
                let authenticatorData = credentialAssertion.rawAuthenticatorData,
                let userId = credentialAssertion.userID else {
            return
        }
        
        Task {
            try await requestLoginFinish(
                userName: userName,
                credentialId: credentialId,
                userId: userId,
                clientDataJSON: clientDataJSON,
                authenticatorData: authenticatorData,
                signature: signature
            )
        }
    ...
    }
}

authorizationController(controller:didCompleteWithAuthorization:)にコールバックが呼ばれる点も同一ですが、ログインの場合は ASAuthorizationPlatformPublicKeyCredentialAssertion へキャストします。

取得できるプロパティも登録の場合と異なります。

signature 秘密鍵を使って生成した署名データです。 サーバーサイドでは、クライアントから受け取ったauthenticatorDataとclientDataJSON、そして登録時に保存した公開鍵を使って検証を行います。

authenticatorData アカウント登録時のattestation Objectに含まれていたものと似ていますが、こちらは公開鍵が含まれていません。

最後に

今回はWebAuthnをベースとしたPasskeys in iCloud Keychainの実装例を紹介しました。 WebAuthnはセキュアで便利な認証システムですが、まだまだ対応しているサービスが少ないのが実情です。

ですが、今回紹介したPasskeys in iCloud KeychainはWebAuthnの仕組みをアプリからも手軽に利用できるようになるため、利用ハードルを一段下げ、ユーザー体験の向上にも繋がると思います。この機能をきっかけにWebAuthnがますます普及していくことを期待しています。


Kyashではエンジニアを絶賛募集中です。カジュアル面談も実施していますので、もしご興味ありましたら以下のリンクからお気軽にお申し込みください。

open.talentio.com

docs.google.com