Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions lib/web_authn_lite/operation/restore_credential.ex
Original file line number Diff line number Diff line change
@@ -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:<Base64URL(SHA-256(signing_cert))>`
- リリース用とデバッグ用の両ハッシュを許容する設計を推奨
- **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:<Base64URL(SHA-256(signing_cert))>",
challenge: challenge
})
```

`type` を指定しない場合は #{inspect(@restore_credential_type)} を使用する。
実際の Android デバイスから送られる type 値を確認した上で、
必要であれば `type` オプションで上書きすること。

`origin` には Android アプリの APK 署名ハッシュを指定する:
`android:apk-key-hash:<Base64URL(SHA-256(signing_cert))>`
リリース用とデバッグ用の証明書が異なるため、複数の 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
204 changes: 204 additions & 0 deletions test/lib/web_authn_lite/operation/restore_credential_test.exs
Original file line number Diff line number Diff line change
@@ -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
Loading