diff --git a/lib/web_authn_lite/operation/restore_credential.ex b/lib/web_authn_lite/operation/restore_credential.ex new file mode 100644 index 0000000..be9de0d --- /dev/null +++ b/lib/web_authn_lite/operation/restore_credential.ex @@ -0,0 +1,171 @@ +defmodule WebAuthnLite.Operation.RestoreCredential do + @moduledoc """ + Android Restore Credentials のアサーションを検証する関数群 + + Android の Credential Manager API で GetRestoreCredentialOption を使用して + 取得したクレデンシャルをサーバー側で検証するためのモジュール。 + + ## 通常の WebAuthn 認証 (Operation.Authenticate) との主な違い + + - **UP (User Presence) フラグ** — バックグラウンドで実行されるため `false` になりうる + - **origin 形式** — ブラウザの `https://...` ではなく、APK 署名証明書ハッシュを使用 + - 形式: `android:apk-key-hash:` + - リリース用とデバッグ用の両ハッシュを許容する設計を推奨 + - **BE (Backup Eligible) フラグ** — Restore Credentials では常に `true` になるため、 + `be_required: true` を指定することでバックアップ対象クレデンシャルを識別可能 + - **clientDataJSON の type** — 現時点では `"webauthn.get"` が使用されると見られるが、 + Android の実装によって異なる可能性がある。`type` オプションで上書き可能 + - **AAGUID** — 全ゼロ (`00000000-0000-0000-0000-000000000000`) になる + + ## DB 管理上の注意点 + + Restore Credentials はプロトコルレベルでは通常パスキーと区別できないため、 + DB 側でクレデンシャルの種別(restore_key / passkey)を管理することを推奨する。 + Restore Credentials はユーザー向けのパスキー管理 UI に表示しないよう注意。 + + https://developer.android.com/distribute/aep/aep-req-restore-credentials + https://developer.android.com/identity/sign-in/restore-credentials-implementation + """ + + alias WebAuthnLite.{ClientDataJSON, AuthenticatorData, Signature, StorablePublicKey} + + @restore_credential_type "webauthn.get" + + @rounded_error_client_data_json {:error, :invalid_client_data_json} + @rounded_error_authenticator_assertion {:error, :invalid_authenticator_assertion} + + @doc """ + clientDataJSON を検証して構造体を返す。 + + ``` + {:ok, client_data_json} = + WebAuthnLite.Operation.RestoreCredential.validate_client_data_json(%{ + client_data_json: encoded_client_data_json, + origin: "android:apk-key-hash:", + challenge: challenge + }) + ``` + + `type` を指定しない場合は #{inspect(@restore_credential_type)} を使用する。 + 実際の Android デバイスから送られる type 値を確認した上で、 + 必要であれば `type` オプションで上書きすること。 + + `origin` には Android アプリの APK 署名ハッシュを指定する: + `android:apk-key-hash:` + リリース用とデバッグ用の証明書が異なるため、複数の origin を + 上位のアプリケーション層で管理することを推奨する。 + """ + @spec validate_client_data_json(params :: map) :: + {:ok, client_data_json :: ClientDataJSON.t()} | {:error, term} + def validate_client_data_json(%{ + client_data_json: encoded_client_data_json, + origin: origin, + challenge: challenge + } = params) do + type = Map.get(params, :type, @restore_credential_type) + + case ClientDataJSON.validate( + encoded_client_data_json, + type, + origin, + challenge + ) do + {:ok, _client_data_json} = valid -> valid + {:error, _} = invalid -> invalid + _ -> @rounded_error_client_data_json + end + end + + @doc """ + Restore Credential のアサーションを検証して構造体を返す。 + + 通常の認証と異なり、デバイス移行時は UP (User Presence) フラグが + false になることがあるため、用途に応じて `up_required` を設定する。 + + Restore Credentials では authenticatorData の BE (Backup Eligible) と + BS (Backup State) フラグも検証できる。 + - `be_required: true` — クレデンシャルがバックアップ対象であることを要求する + - `bs_required: true` — クレデンシャルが実際にバックアップ済みであることを要求する + + ``` + {:ok, storable_public_key, authenticator_data} = + WebAuthnLite.Operation.RestoreCredential.validate_authenticator_assertion(%{ + credential_id: credential_id, + signature: encoded_signature, + authenticator_data: encoded_authenticator_data, + client_data_json: encoded_client_data_json, + public_keys: [storable_public_key], + rp_id: rp_id, + up_required: false, + uv_required: false, + be_required: true, + bs_required: true + }) + ``` + """ + @spec validate_authenticator_assertion(params :: map) :: + {:ok, updated_storable_public_key :: StorablePublicKey.t(), + authenticator_data :: WebAuthnLite.AuthenticatorData.t()} + | {:error, term} + def validate_authenticator_assertion(%{ + credential_id: credential_id, + signature: encoded_signature, + authenticator_data: encoded_authenticator_data, + client_data_json: encoded_client_data_json, + public_keys: public_keys, + rp_id: rp_id, + up_required: up_required, + uv_required: uv_required + } = params) do + be_required = Map.get(params, :be_required, false) + bs_required = Map.get(params, :bs_required, false) + + with {:ok, authenticator_data} <- AuthenticatorData.decode(encoded_authenticator_data), + {:ok, client_data_json} <- ClientDataJSON.decode(encoded_client_data_json), + public_key when not is_nil(public_key) <- lookup_public_key(public_keys, credential_id) do + cond do + !Signature.valid?( + encoded_signature, + authenticator_data, + client_data_json, + public_key.public_key + ) -> + {:error, :invalid_signature} + + !AuthenticatorData.valid_rp_id_hash?(rp_id, authenticator_data.rp_id_hash) -> + {:error, :invalid_rp_id_hash} + + up_required && !authenticator_data.flags.up -> + {:error, :up_required} + + uv_required && !authenticator_data.flags.uv -> + {:error, :uv_required} + + be_required && !authenticator_data.flags.be -> + {:error, :be_required} + + bs_required && !authenticator_data.flags.bs -> + {:error, :bs_required} + + public_key.sign_count > 0 && authenticator_data.sign_count > 0 && + public_key.sign_count >= authenticator_data.sign_count -> + {:error, :invalid_sign_count} + + true -> + {:ok, + %StorablePublicKey{ + credential_id: public_key.credential_id, + public_key: public_key.public_key, + sign_count: authenticator_data.sign_count + }, authenticator_data} + end + else + {:error, _} = invalid -> invalid + _ -> @rounded_error_authenticator_assertion + end + end + + defp lookup_public_key(public_keys, credential_id) do + Enum.find(public_keys, fn public_key -> public_key.credential_id == credential_id end) + end +end diff --git a/test/lib/web_authn_lite/operation/restore_credential_test.exs b/test/lib/web_authn_lite/operation/restore_credential_test.exs new file mode 100644 index 0000000..b0d801c --- /dev/null +++ b/test/lib/web_authn_lite/operation/restore_credential_test.exs @@ -0,0 +1,204 @@ +defmodule WebAuthnLite.Operation.RestoreCredentialTest do + use ExUnit.Case, async: false + + alias WebAuthnLite.StorablePublicKey + alias WebAuthnLite.Operation.RestoreCredential + + # Keychain のテストデータを流用(BE=true, BS=true フラグ付き) + # 実際の Android Restore Credentials のデータが取得でき次第、専用データに置き換える + @encoded_attestation_object_keychain "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYo3mm9u6vuaVeN4wRgDTidR5oL6ufLTCrE9ISVYbOGUddAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCPmvJjrA9Cj6TU2H1Oa2r8fB9pGpQECAyYgASFYICdFZVoxrv4JsVRQRND88TV_Q917IgdcpF2jDg4cFelXIlgg5hQAmXqwfBISWno5v4dk1byQ0iUiq2P63yb1PfrFHmc" + @encoded_authenticator_data_keychain "o3mm9u6vuaVeN4wRgDTidR5oL6ufLTCrE9ISVYbOGUcdAAAAAA" + @encoded_client_data_json_keychain "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiS001UDA1M3o5SEtES25mREJDZEU2ZyIsIm9yaWdpbiI6Imh0dHBzOi8vZXhhbXBsZS5jb20iLCJjcm9zc09yaWdpbiI6ZmFsc2V9" + @encoded_signature_keychain "MEQCIDWMoLHFQkcZLybJQ_PsFam6LNxVS7eWXNXsinqB3FkZAiAq1VCuISjiGkJznuxustoMoMBfh5n-XLSqHjxj0hTYVQ" + @sample_rp_id "example.com" + + describe "validate_client_data_json" do + # Android Restore Credentials の clientDataJSON(type: "webauthn.get") + @android_origin "android:apk-key-hash:sjYxqUM11Op8oHJuOdbrsCqtvYvbKHhQoKBlt28dLec" + + @webauthn_get_client_data_json Base.url_encode64( + Jason.encode!(%{ + "type" => "webauthn.get", + "challenge" => "test-challenge", + "origin" => "android:apk-key-hash:sjYxqUM11Op8oHJuOdbrsCqtvYvbKHhQoKBlt28dLec", + "androidPackageName" => "com.example.app" + }), + padding: false + ) + + @passkey_get_client_data_json Base.url_encode64( + Jason.encode!(%{ + "type" => "passkey.get", + "challenge" => "test-challenge", + "origin" => "android:apk-key-hash:sjYxqUM11Op8oHJuOdbrsCqtvYvbKHhQoKBlt28dLec" + }), + padding: false + ) + + test "デフォルトの type(webauthn.get)と Android APK origin で検証成功" do + assert {:ok, client_data_json} = + RestoreCredential.validate_client_data_json(%{ + client_data_json: @webauthn_get_client_data_json, + origin: @android_origin, + challenge: "test-challenge" + }) + + assert client_data_json.type == "webauthn.get" + end + + test "type オプションで passkey.get を指定して検証成功" do + assert {:ok, client_data_json} = + RestoreCredential.validate_client_data_json(%{ + client_data_json: @passkey_get_client_data_json, + origin: @android_origin, + challenge: "test-challenge", + type: "passkey.get" + }) + + assert client_data_json.type == "passkey.get" + end + + test "type が一致しない場合はエラー" do + assert {:error, :invalid_type} = + RestoreCredential.validate_client_data_json(%{ + client_data_json: @passkey_get_client_data_json, + origin: @android_origin, + challenge: "test-challenge" + }) + end + + test "origin が一致しない場合はエラー" do + assert {:error, :invalid_origin} = + RestoreCredential.validate_client_data_json(%{ + client_data_json: @webauthn_get_client_data_json, + origin: "android:apk-key-hash:wronghashvalue", + challenge: "test-challenge" + }) + end + + test "challenge が一致しない場合はエラー" do + assert {:error, :invalid_challenge} = + RestoreCredential.validate_client_data_json(%{ + client_data_json: @webauthn_get_client_data_json, + origin: @android_origin, + challenge: "wrong-challenge" + }) + end + end + + describe "validate_authenticator_assertion" do + setup do + {:ok, attestation_object} = + WebAuthnLite.AttestationObject.decode(@encoded_attestation_object_keychain) + + storable_public_key = %StorablePublicKey{ + credential_id: attestation_object.auth_data.attested_credential_data.credential_id, + public_key: attestation_object.auth_data.attested_credential_data.credential_public_key, + sign_count: attestation_object.auth_data.sign_count + } + + {:ok, storable_public_key: storable_public_key} + end + + test "BE=true, BS=true のクレデンシャルで be_required/bs_required=true を検証成功", + %{storable_public_key: storable_public_key} do + assert {:ok, updated_storable_public_key, authenticator_data} = + RestoreCredential.validate_authenticator_assertion(%{ + credential_id: storable_public_key.credential_id, + signature: @encoded_signature_keychain, + authenticator_data: @encoded_authenticator_data_keychain, + client_data_json: @encoded_client_data_json_keychain, + public_keys: [storable_public_key], + rp_id: @sample_rp_id, + up_required: true, + uv_required: true, + be_required: true, + bs_required: true + }) + + assert authenticator_data.flags.be == true + assert authenticator_data.flags.bs == true + assert updated_storable_public_key.credential_id == storable_public_key.credential_id + end + + test "be_required/bs_required を指定しない場合(デフォルト false)でも成功", + %{storable_public_key: storable_public_key} do + assert {:ok, _updated_storable_public_key, _authenticator_data} = + RestoreCredential.validate_authenticator_assertion(%{ + credential_id: storable_public_key.credential_id, + signature: @encoded_signature_keychain, + authenticator_data: @encoded_authenticator_data_keychain, + client_data_json: @encoded_client_data_json_keychain, + public_keys: [storable_public_key], + rp_id: @sample_rp_id, + up_required: true, + uv_required: true + }) + end + + test "Chrome データ(BE=false, BS=false)で be_required=true はエラー" do + # Chrome のデータは BE=false, BS=false のため、be_required=true でエラーになることを確認 + encoded_attestation_object_chrome = + "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViko3mm9u6vuaVeN4wRgDTidR5oL6ufLTCrE9ISVYbOGUdFAAAAAK3OAAI1vMYKZIsLJfHwVQMAILv_1TM4JzTox-FHSHgOFEymS7zmPRK8YgtpTR_9GUUbpQECAyYgASFYIBE0VulC_XRULa4FpJ7MqvWPluXIOHWvwqq3N64Wu8lhIlggVpcik5uSvSvNTdlL2Okjjtu4bE-u1OAp8to2saFVa1M" + + encoded_authenticator_data_chrome = "o3mm9u6vuaVeN4wRgDTidR5oL6ufLTCrE9ISVYbOGUcFAAAAAA" + + encoded_client_data_json_chrome = + "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiS001UDA1M3o5SEtES25mREJDZEU2ZyIsIm9yaWdpbiI6Imh0dHBzOi8vZXhhbXBsZS5jb20iLCJjcm9zc09yaWdpbiI6ZmFsc2V9" + + encoded_signature_chrome = + "MEUCIDcWFNjAM_g10HjzzG3kD0Dzj28LIk4kWr9IkJST1SzCAiEAkrIctvKzDEh0wZ0WlN2ghLDgkIQp2p7bzT8czK0_lLo" + + {:ok, attestation_object} = + WebAuthnLite.AttestationObject.decode(encoded_attestation_object_chrome) + + storable_public_key = %StorablePublicKey{ + credential_id: attestation_object.auth_data.attested_credential_data.credential_id, + public_key: attestation_object.auth_data.attested_credential_data.credential_public_key, + sign_count: attestation_object.auth_data.sign_count + } + + assert {:error, :be_required} = + RestoreCredential.validate_authenticator_assertion(%{ + credential_id: storable_public_key.credential_id, + signature: encoded_signature_chrome, + authenticator_data: encoded_authenticator_data_chrome, + client_data_json: encoded_client_data_json_chrome, + public_keys: [storable_public_key], + rp_id: @sample_rp_id, + up_required: true, + uv_required: true, + be_required: true, + bs_required: false + }) + end + + test "不正な署名はエラー", %{storable_public_key: storable_public_key} do + assert {:error, :invalid_signature} = + RestoreCredential.validate_authenticator_assertion(%{ + credential_id: storable_public_key.credential_id, + signature: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + authenticator_data: @encoded_authenticator_data_keychain, + client_data_json: @encoded_client_data_json_keychain, + public_keys: [storable_public_key], + rp_id: @sample_rp_id, + up_required: false, + uv_required: false + }) + end + + test "不正な RP ID はエラー", %{storable_public_key: storable_public_key} do + assert {:error, :invalid_rp_id_hash} = + RestoreCredential.validate_authenticator_assertion(%{ + credential_id: storable_public_key.credential_id, + signature: @encoded_signature_keychain, + authenticator_data: @encoded_authenticator_data_keychain, + client_data_json: @encoded_client_data_json_keychain, + public_keys: [storable_public_key], + rp_id: "wrong.example.com", + up_required: false, + uv_required: false + }) + end + end +end