『入る学科間違えた高専生』の日記

プログラミングのコードを書いたりする予定です。あとは日記等。あといつまで高専生やねん

Python と iOS でパスキー認証の実装をする

はじめに

パスキー (Passkeys) の実装をサーバサイドは Python (Django) でクライアントは iOS で実装してみたので備忘録がてらの紹介です。

パスキー自体の説明は省略します。

Apple のサイトでもそれっぽいことが書かれていたのでリンクだけ

developer.apple.com

挙動デモ

実装サンプル

github.com

いいねと思ったら Star ください⭐️⭐️

環境

サーバサイドでは Django の他に webauthn を使用しています。
Django を選んだのはとりあえずデータベース付きの Web フレームワークが欲しかったので選んでいるだけで流行りの FastAPI だったり Flask だったりなんだっていいです。
また、今回は Django REST framework 等は使わずに Django の機能のみで実装しています。

基本的にサーバサイドと iOS アプリとの通信は Web API による通信を想定しているのでリクエストボディに流れてきた JSON ペイロードrequest オブジェクトから json プロパティでアクセスできるようにデコレータを実装しています。

デコレータ実装

import json
from functools import wraps

from django.http.response import JsonResponse


def request_body_json(func):
    def decorator(view_func):
        def wrapper(request, *args, **kwargs):
            try:
                data = json.loads(request.body)
                setattr(request, "json", data)
                return view_func(request, *args, **kwargs)
            except json.decoder.JSONDecodeError as e:
                return JsonResponse({"error": str(e)}, status=400)

        return wrapper

    return wraps(func)(decorator(func))

流れ

結構適当に作ってるのとまだまだ知識不足なので間違った部分があれば指摘いただけると幸いです...
認証器部分が iOS だとブラックボックス化されてるような感じがあるので端折ってます。

パスキー登録

パスキー認証

流れはどちらともサーバサイドにチャレンジを要求して、ローカルでユーザ検証をしてからパスキーに登録もしくはアクセスをするという流れになります。

パスキーの登録

登録しなければ認証もなにもないので登録をする作業から始めます。
登録時のベストプラクティスがまだ定まっていないということなので、とりあえずメールアドレスを入力してもらってサーバサイドに送信してユーザ作成をしてチャレンジと user_id をサーバサイドから返却してもらうように実装をしました。

クライアントの実装

登録と認証どちらとも Relying Party を static let で定義しています。

static let relyingPartyIdentifier = "example.com"

一般的にはこの値はサービスのドメイン名になります。今回は特にパブリックに公開していないものの iOS アプリでパスキーを使用する際に apple-app-site-association の設定が必要になるため ngrok を使用して開発時に外部からアクセスできるようにしていました。

apple-app-site-association の定義

{
  "webcredentials": {
    "apps": [
      "{{ YOUR_TEAM_ID }}.{{ IOS_APP_BUNDLE_ID }}"
    ]
  }
}

という感じで定義をしています。

developer.apple.com

iOS アプリ側にも Associated domains の追加が必要になります。

webcredentials:example.com

let response = try await requestRegistrationBegin(email: email) // サーバサイドからチャレンジと user_id を取得する

let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: Self.relyingPartyIdentifier)
let registrationRequest = provider.createCredentialRegistrationRequest(
    challenge: response.challenge,
    name: email,
    userID: Data(base64Encoded: response.userID) ?? .init()
)

let controller = ASAuthorizationController(authorizationRequests: [registrationRequest])
controller.delegate = self
controller.presentationContextProvider = self
controller.performRequests()

メールアドレスをサーバサイドに送信してチャレンジを要求します。
その取得されたチャレンジと user_id を使用して登録リクエストを作成し Sign in with Apple 等でおなじみの ASAuthorizationController に渡してリクエストを表示します。*1
また登録リクエスト作成時には userID は Swift の Data 型で渡す必要があります。 Swift では Int 型から Data 型への変換がちょっと面倒*2 なのでサーバサイドで予め Pythonbytes に変換し Base64 エンコードしレスポンスに含めています。

Touch ID や Face ID 等でユーザの認証が成功すると AuthorizationControllerDelegateauthorizationController(controller:didCompleteWithAuthorization:) *3 メソッドが呼び出されるので第2引数の authorization: ASAuthorization を使用してサーバサイドに認証情報を送信します。
この時点ですでに iCloud Keychain *4にパスキーが登録されます。

func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
    switch authorization.credential {
    case let credential as ASAuthorizationPlatformPublicKeyCredentialRegistration:
        let credentialID = credential.credentialID // Data 型
        let attestationObject = credential.rawAttestationObject // Optional<Data> 型
        let clientDataJSON = credential.rawClientDataJSON // Data 型

        let jsonObject: [String: Any] = [
            "user_id": userID // チャレンジで取得した String 型の user_id,
            "credential_id": credentialID.base64EncodedString(),
            "attestation_object": attestationObject?.base64EncodedString(),
            "client_data_json": clientDataJSON.base64Encoded()
        ].compactMapValues { $0 }
        let httpBody = try? JSONSerialization.data(withJSONObject: jsonObject)
        // サーバサイドに送信
    default:
        break
    }
}

API 通信においては JSON ペイロードData 型を含めて送信することはできないので Base64 エンコードして JSON ペイロードにしています。本来であれば URL セーフな Base64 エンコードがいいような気がしますが Swift ではこの操作も面倒なのでちょっとズルしました。

サーバサイドの実装

チャレンジ作成 API は今回は詳しい説明は省略します。以下のコード部分です。

github.com

In order to prevent replay attacks, the challenges MUST contain enough entropy to make guessing them infeasible. Challenges SHOULD therefore be at least 16 bytes long.
引用: https://www.w3.org/TR/webauthn-2/#sctn-cryptographic-challenges

ということなので 16バイト以上のチャレンジが必要とのことなので今回は以下のようにとりあえず 32バイトでチャレンジを生成するようにしました。

import secrets

challenge = secrets.token_bytes(32)

レスポンスに含める際にこちらも Base64 エンコードしています。

続いて、クライアント側でパスキー登録完了後に認証情報を受け取る API の実装です。

github.com

とりあえず受け取った JSON ペイロードの中身はすべて Base64 エンコード済みの bytes なので Base64 デコードして元の形に戻します。
ペイロード内の user_id と保存済みのチャレンジのユーザが一致するなど軽く確認したあとに認証情報の検証に進みます。

認証情報の検証自体は、 webauthn パッケージが全てよしなにやってくれているようなのでお任せします。
また、 webauthn パッケージ以外にも fido2 があります。こちらでも同様に検証が可能です。

webauthn パッケージの verify_registration_response 関数(以下、登録検証関数)を使用します。
ここで渡す credentialJavaScriptnavigator.credentials.create() で取得されるレスポンスを受け取る前提として定義されているようなので JSON ペイロードで受け取ったデータをその形に整形する必要があります。

from webauthn.helpers import bytes_to_base64url

credential = {
    "id": bytes_to_base64url(credential_id),
    "rawId": bytes_to_base64url(credential_id),
    "response": {
        "attestationObject": bytes_to_base64url(attestation_object),
        "clientDataJSON": bytes_to_base64url(client_data_json),
    },
    "type": "public-key",
    "clientExtensionResults": {},
    "authenticatorAttachment": "platform",
}

webauthn パッケージのヘルパー関数 bytes_to_base64_url を使用して再度 Base64 エンコードを施すようにします。
authenticatorAttachmentcross-platform になる可能性もあるので JSON ペイロードに含めて受け取るようにしても良いかもしれません。

次に上で生成した credential と期待値情報を含めて登録検証関数に渡します。

registration_verification = verify_registration_response(
    credential=credential,
    expected_challenge=base64url_to_bytes(challenge),
    expected_origin="https://example.com",
    expected_rp_id="example.com",
    require_user_verification=True,
)

challenge もデータベースに保存する際に Base64 エンコードしていたのでヘルパー関数 base64url_to_bytes を使って bytes に戻して登録検証関数に渡します。あとは origin とか rp_id とか適当に突っ込む。
検証に失敗すると内部で何らかの例外が発生するので発生せずに VerifiedRegistration オブジェクトが取得できれば検証成功となります。

検証成功したら、 VerifiedRegistration オブジェクトの credential_idpublic_key をデータベースに保存して検証できるようにしておきます。 credential_idBase64 エンコードpublic_keycryptography のオブジェクトを使用しているので復元が面倒なので pickle を使って bytes に変換したあとにデータベースに保存しておきました。

パスキーでの認証

クライアント実装

let response = try await requestAuthenticateBegin(email: email) // サーバサイドからチャレンジを取得する

let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: Self.relyingPartyIdentifier)
let assertionRequest = provider.createCredentialAssertionRequest(challenge: response.challenge)

let controller = ASAuthorizationController(authorizationRequests: [assertionRequest])
controller.delegate = self
controller.presentationContextProvider = self
controller.performRequests()

サーバサイドからチャレンジを取得したあとにパスキーが登録済みの Relying Party でアサーションリクエストを作成をします。
ここでは登録済みですでに user_idアサーションリクエストに渡す必要はありません。(渡せない)

パスキーの登録時と同様に Touch ID や Face ID 等でユーザの認証が成功すると AuthorizationControllerDelegateauthorizationController(controller:didCompleteWithAuthorization:) メソッドが呼び出されるので第2引数の authorization: ASAuthorization を使用してサーバサイドに認証情報を送信します。

func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
    switch authorization.credential {
    case let credential as ASAuthorizationPlatformPublicKeyCredentialRegistration:
        // 登録時の実装のため省略
    case let credential as ASAuthorizationPlatformPublicKeyCredentialAssertion:
        let userID = credential.userID // Data 型
        let credentialID = credential.credentialID // Data 型
        let authenticatorData = credential.rawAuthenticatorData // Data 型
        let signature = credential.signature // Data 型
        let clientDataJSON = credential.rawClientDataJSON // Data 型
        let jsonObject: [String: Any] = [
            "user_id": userID.base64EncodedString(),
            "credential_id": credentialID.base64EncodedString(),
            "authenticator_data": authenticatorData.base64EncodedString(),
            "signature": signature.base64EncodedString(),
            "client_data_json": clientDataJSON.base64EncodedString()
        ]
        let httpBody = try? JSONSerialization.data(withJSONObject: jsonObject)
        // サーバサイドに送信
    default:
        break
    }
}

ここではサーバサイドでパスキーを特定するために user_idcredential_id を追加しています。
また、 ASAuthorizationPlatformPublicKeyCredentialAssertion オブジェクトから取得してサーバサイドに送信するプロパティはすべて Data 型なので Base64 エンコードを施して JSON ペイロードに含めて送信しています。

サーバサイド実装

チャレンジ作成 API はここでも省略します。実装は以下です。

github.com

至極簡単ですね。

続いて、クライアント側でパスキー認証後に認証情報を受け取る API の実装です。

github.com

ちょっと長いですが、パスキー登録の API 実装とやってることはほとんど変わりありません。
チャレンジがユーザものかどうかを検証して、ユーザに登録済みのパスキーを JSON ペイロードに含まれる credential_id から取得し、認証情報を検証する流れです。

webauthn パッケージの verify_authentication_response 関数(以下、認証検証関数)を使用します。
ここでも credentialJavaScriptnavigator.credentials.get() で取得されるレスポンスを受け取る前提として定義されているようなので JSON ペイロードで受け取ったデータをその形に整形する必要があります。

from webauthn.helpers import bytes_to_base64url

credential = {
    "id": bytes_to_base64url(credential_id),
    "rawId": bytes_to_base64url(credential_id),
    "response": {
        "authenticatorData": bytes_to_base64url(authenticator_data),
        "clientDataJSON": bytes_to_base64url(client_data_json),
        "signature": bytes_to_base64url(signature),
    },
    "type": "public-key",
    "authenticatorAttachment": "platform",
    "clientExtensionResults": {},
}

authentication_verification = verify_authentication_response(
    credential=credential,
    expected_challenge=base64url_to_bytes(challenge),
    expected_rp_id="example.com",
    expected_origin="https://example.com",
    credential_public_key=passkey.authenticate_data.credential_public_key,
    credential_current_sign_count=passkey.sign_count,
    require_user_verification=True,
)

passkey.authenticate_data.credential_public_key では picklebytes に変換した cryptography のオブジェクトを元の形に戻しています。*5
ここでも認証検証関数内で検証に失敗すると何らかの例外が発生し、成功したら VerifiedAuthentication オブジェクトが取得されます。
このオブジェクト自体の内容は大したことはないと思っているんですが、 JSON ペイロードで受け取った credential_idVerifiedAuthentication オブジェクトの credential_id が一致するかの確認と、 new_sign_count を使ってデータベース上の該当パスキーのカウンターを変更しておくと良いかもしれません。

あとは、自分のサービスの認証基盤に沿うような API キー等を発行する流れに持っていけば良いでしょう!

最後に

iOS のパスキーの実装自体は、サーバサイドがなくても*6確認程度ぐらいはできるのでサンプルプロジェクト*7をダウンロードして確認することができます。
ただそのときに取得される情報を使ってどうやって検証を行うのかを自分なりに学習できたのでとても良かったです。

Apple がパスキーを発表したとき*8*9に「めっちゃすごいやん!!」ってなってたんですが、でも実際に実装するとなると「クライアント実装は簡単だけどサーバサイドはどうなん?」という疑問がなんとなく解決できました。

おまけ

iOS 17 以上であれば iCloud Keychain ではなく 1Password でパスキーが使用ができるようになりました*10
しかしながら、今回の検証で使用した端末が iOS 17.1.1 でパスキー登録時に iOS アプリから取得できる clientDataJSON が空で取得されるので登録が行えませんでした。

1password.community

詳しいことはわからないのですが、普段から 1Password を使ってパスキーを使用している自分にとってはちょっと厄介です。

We are working with our partners at Apple to find a resolution to this issue and early reports indicate that iOS 17.2 Beta 1 includes a fix for the issue. Are you able to test saving and using a passkey with 1Password and your app using the latest beta of iOS?

上記より、 iOS 17.2 Beta 1 で修正がされたということで iOS 17.2.1 にアップデートをして再度検証をしてみたところ無事パスキーの登録及び認証を行うことができました。

iOS 17.2 未満を使用しているユーザにはアップデートを促すか iCloud Keychain のみが使用できることを表示するなど何らかのアプローチを取ると良いかもしれませんね。

参考

blog.kyash.co

github.com

techbookfest.org

*1:AuthenticationService フレームワークのインポートが必要

*2: Int 型から String 型を通して Data 型はすぐなのでそちらを使うのでもアリ

*3:https://developer.apple.com/documentation/authenticationservices/asauthorizationcontrollerdelegate/3153050-authorizationcontroller

*4:ここで使用しているのは iCloud Keychain ですが 1Password 等でも同様

*5:実際の実装では webauthn パッケージの VerifiedRegistration を保存しています

*6:AASA は必要

*7:https://developer.apple.com/documentation/authenticationservices/connecting_to_a_service_with_passkeys

*8:https://developer.apple.com/videos/play/wwdc2021/10106/

*9:https://developer.apple.com/videos/play/wwdc2022/10092/

*10:https://blog.1password.com/save-use-passkeys-web-ios/