メインコンテンツまでスキップ

Attestation-Based Client Authentication 実装ガイド

このドキュメントの目的​

attest_jwt_client_auth(draft-ietf-oauth-attestation-based-client-auth-11)が idp-server の中でどう組み立てられているかを、コードを触る人向けに説明します。

プロトコルとしての挙動・設定値・エラーは プロトコル仕様 を参照してください。ここでは「どのクラスが何を担当し、どこを触れば拡張できるか」を扱います。

前提知識​


全体像​

他の認証方式と違い、2つの独立したフェーズを持ちます。

 ① 登録フェーズ(インストール時に一度)        ② 認証フェーズ(リクエストごと)
───────────────────────────────────── ─────────────────────────────
POST /{tenant}/v1/client-instances OAuth-Client-Attestation
↓ OAuth-Client-Attestation-PoP
ClientInstanceRegistrationIdTokenVerifier ↓
(ID トークン: 誰か、nonce で鍵に束縛) AttestJwtClientAuthAuthenticator
↓ ↓
PlatformAttestationVerifier ClientAttestationJwtVerifier
(端末・アプリの証明を検証) + ClientAttestationPopJwtVerifier
↓
client_instance に鍵を登録し、利用者に束縛

①は registered_instance_key(自己署名)モードのときだけ通る経路です。attester_jwks モードでは Client Attester が発行するため、①は不要になります。


モジュール構成​

モジュール責務
idp-server-core型・SPI・登録フローのユースケース。プロトコルの語彙を持つ
idp-server-core-extension-attestation検証の実装。JWT 2種とプラットフォーム証明
idp-server-platform外部ライブラリのラッパー(JOSE / X.509 / ASN.1)

外部ライブラリは platform でラップするのがこのプロジェクトの方針です。BouncyCastle を使うのは platform.asn1 と platform.x509 だけで、拡張モジュールはそれらの型だけを見ます。


認証フェーズの実装​

クラスの並び​

クラス担当
AttestJwtClientAuthAuthenticatorClientAuthenticator SPI の実装。2つの Verifier を順に呼ぶ
ClientAttestationJwtVerifierAttestation JWT(draft-11 §7.1)
ClientAttestationPopJwtVerifierPoP JWT(§7.2)。Challenge の検証も持つ
ClientAttestationKeyResolverstrust_source で鍵の解決方法を切り替えるレジストリ
StaticJwksClientAttestationKeyResolverattester_jwks。設定の JWKS で検証
X5cClientAttestationKeyResolverx5c。JOSE ヘッダのチェーンをピン留めしたルートまで検証し、リーフの鍵を返す
RegisteredInstanceKeyResolverregistered_instance_key。kid を instance_id として鍵を引く

鍵解決の切り替え​

ClientAttestationKeyResolvers が ClientAttestationTrustSource をキーにしたレジストリになっています。信頼モデルを増やすときはここに実装を足します。

// ClientAttestationKeyResolvers
resolvers.put(ClientAttestationTrustSource.registered_instance_key, new RegisteredInstanceKeyResolver(...));
resolvers.put(ClientAttestationTrustSource.attester_jwks, new StaticJwksClientAttestationKeyResolver());
resolvers.put(ClientAttestationTrustSource.x5c, new X5cClientAttestationKeyResolver());

リゾルバは JWKS 文字列を返す契約です。x5c のように単一の鍵に辿り着く実装は JwkParser.parseFromCertificate で 1 鍵の JWKS にして返します。

attester_jwks では JWKS に kid か alg が要ります

JsonWebSignatureVerifierFactory は kid が無いと alg で鍵を引きます(JsonWebKeys.findByAlgorithm)。client_attestation_attester_jwks の鍵に kid も alg も無いと、署名検証に到達する前に 401 になります。ログからは「鍵が見つからない」以上のことが読めないので、切り分けにくい形です。

x5c ではこの分岐がありません。X5cClientAttestationKeyResolver が証明書から鍵を取り出すとき、提示された alg をその鍵に付けてから返します。鍵の材料は検証済み証明書のもので固定なので、ヘッダが偽った alg は署名検証で落ちます。

registered_instance_key では kid が鍵の索引になります

RegisteredInstanceKeyResolver は JOSE ヘッダの kid を ClientInstanceIdentifier として扱います。kid が無いと鍵を解決できず、署名検証に到達する前に失敗します。クライアント実装のつまずきどころなので、401 の切り分け時はまずここを見てください。

DI の組み立て​

ClientAuthenticationHandler は起動時に一度だけ組み立てて注入します(IdpServerApplication)。ClientAuthenticator の SPI は no-arg の ServiceLoader なので、依存を持つ実装は ClientAuthenticatorFactory 経由で ApplicationComponentContainer から受け取ります。


登録フェーズの実装​

PlatformAttestationVerifier SPI​

プラットフォーム証明の検証は PlatformAttestationVerifier に切られています。

public interface PlatformAttestationVerifier {
String platform(); // platform_evidence.platform の値
PlatformAttestationEvidence verify(PlatformAttestationVerificationRequest request);
}

PlatformAttestationVerificationRequest は tenant / clientConfiguration / challenge / instanceKey / evidence を持つ record です。

verify は、検証で確かめたことを PlatformAttestationEvidence として返します。登録サービスはこれを client_instance.attestation_evidence に保存します(中身は Attestation-Based Client Authentication)。

返すのは PlatformAttestationEvidence.of(platform, key, app, chain) です。key は鍵について確かめたこと、app は一致したアプリの識別子、chain は提示された証明書チェーンです。

登録は ID トークンでも認証されますが、ID トークンが示すのは「誰か」だけです。どの端末の、どのアプリの、どの鍵かは、この検証だけが示します。ID トークンは nonce = request_hash で鍵に束縛されるので(ClientInstanceRegistrationIdTokenVerifier)、この検証が鍵を確かめて初めて、利用者と端末の鍵がつながります。

実装は3つの束縛をすべて確立すること
  1. チャレンジ — 証拠がこの登録のために作られたこと
  2. インスタンス鍵 — 証拠が登録しようとしている鍵を対象にしていること
  3. アプリ同一性 — 証明されたアプリがこのクライアントのアプリであること

加えて証明書チェーンはピン留めしたルートまで検証すること。検証できないものを通す実装は attest_jwt_client_auth 全体を無意味にします。

束縛が揃っても、鍵そのものの性質は別に確かめること

3つの束縛は「この証拠がこの登録のものか」を決めるだけで、その鍵を登録してよいかは決めません。次の 2 つは束縛がすべて成立していても成立しないことがあります。

確かめること通してしまうもの
鍵がセキュアハードウェアで生成されたこと外部で生成して取り込んだ鍵。セキュアハードウェア内にあっても、生成元に複製が存在する。「鍵は端末から出ない」という ABCA の前提が崩れる
鍵が署名に使えることPoP を署名できない鍵。登録は通り、最初の利用時に署名検証で落ちる

どちらのフィールドをどちらのリストから読むかも意味の一部です。 Android の AuthorizationList は 2 つあり、softwareEnforced は Android プラットフォームが、hardwareEnforced は KeyMint がセキュアハードウェア内で書きます。鍵自身の性質(origin / purpose)はハードウェア側から読まなければ、プラットフォームが知り得ないことをプラットフォームの申告で信じることになります。attestationApplicationId は逆で、どのアプリが要求したかを知っているのはプラットフォームです。

登録の既定は「全拒否」​

PlatformAttestationVerifierPluginLoader は既定で何も登録しません。verifier が1つも無ければ未知 platform として例外になり、登録はすべて拒否されます。端末を確かめられないなら登録させない、という安全側の既定です。

検証を迂回する開発用の仕組みはありません。E2E は、端末と同じ形の証明をテスト用に生成したルートで作り、クライアント設定の override_root_certificates でそのルートを信頼させて、本番と同じ検証を通します(e2e/src/lib/android/keyAttestation.js / e2e/src/lib/ios/appAttest.js)。


Android Key Attestation の実装​

現状で唯一の本番向け verifier です。

クラス構成​

クラス担当
AndroidKeyAttestationVerifierSPI 実装。3つの束縛・鍵の性質・ハードウェア裏付けを判定
AndroidCertificateChainどのルートを信頼するかの判断
AndroidKeyAttestationExtensionKeyDescription のスキーマ解釈
AndroidAttestationApplicationIdpackage name / 署名証明書ダイジェスト
AndroidKeyAttestationSecurityLevelsoftware / trusted_environment / strong_box
AndroidKeyOrigingenerated / imported / securely_imported ほか(origin)
AndroidKeyPurposesign / verify / encrypt ほか(purpose)
AndroidKeyAttestationConfigurationクライアント設定の読み取り
platform.x509.X509CertificateChainチェーンのパース・有効期限・署名連鎖・ルート照合
platform.asn1.Asn1NodeDER の読み取り(BouncyCastle を隠す)

検証の流れ​

Android Key Attestation の検証

ASN.1 スキーマ(規範は AOSP)​

KeyDescription ::= SEQUENCE {
attestationVersion INTEGER,
attestationSecurityLevel SecurityLevel, -- ENUMERATED
keyMintVersion INTEGER,
keyMintSecurityLevel SecurityLevel, -- ENUMERATED
attestationChallenge OCTET_STRING,
uniqueId OCTET_STRING,
softwareEnforced AuthorizationList,
hardwareEnforced AuthorizationList,
}

SecurityLevel ::= ENUMERATED { Software (0), TrustedEnvironment (1), StrongBox (2) }

AuthorizationList から読むフィールドと、どちらのリストから読むかは次のとおりです。

フィールドタグ読むリスト判定
purpose[1] EXPLICIT SET OF INTEGERhardwareEnforcedsign(2) を含むこと
origin[702] EXPLICIT INTEGERhardwareEnforcedKM_ORIGIN_GENERATED(0) であること
rootOfTrust[704] EXPLICIT RootOfTrusthardwareEnforcedverifiedBootState が verified_boot_states に含まれ、require_device_locked なら deviceLocked が真であること
osPatchLevel[706] EXPLICIT INTEGERhardwareEnforcedmin_os_patch_level を設定したとき、その値以上であること
attestationApplicationId[709] EXPLICIT OCTET_STRINGsoftwareEnforced(無ければ hardwareEnforced)package / 署名ダイジェストの照合

attestationApplicationId だけプラットフォームが埋めるフィールドなので softwareEnforced 側です。逆に origin / purpose は鍵自身の性質なので、hardwareEnforced にあるときだけセキュアハードウェアの申告として扱います。

attestationApplicationId をプラットフォームが書く以上、改造した OS はどのアプリの名前でも名乗れます。そこで rootOfTrust(KeyMint がブートローダーの計測から書く)で、検証済みの OS が起動し、ブートローダーがロックされていることを確かめます。これがアプリの照合の前提です。rootOfTrust が無い端末は拒否します。

SecurityLevel は 2 か所にあり、どちらも設定した min_security_level 以上であることを要求します。

フィールド主語
attestationSecurityLevel証明書(attestation)がどこで作られたか
keyMintSecurityLevel鍵を保持する KeyMint がどこで動いているか

前者だけを見ると、TEE で署名された証明書を持つソフトウェア鍵を通します。そのとき hardwareEnforced の中身も同じだけ信頼できないので、origin / purpose の判定ごと意味を失います。

これらは OPTIONAL なフィールドなので、AndroidKeyAttestationExtension は不在をエラーにせず undefined / 空として返し、受け入れ可否は verifier が決めます。「スキーマに従っていない」と「端末が報告しなかった」を混ぜないためです。verifier は報告されなかった origin を拒否します(フィールドが判定の材料そのもので、不在は何の証拠でもないため)。

SecurityLevel は ENUMERATED であって INTEGER ではありません

DER 上のタグが異なるため、INTEGER として読む実装は実機のチェーンで落ちます。テストのフィクスチャを INTEGER で組むと、テストだけ通って実機で壊れる形になります。同じ取り違えは WebAuthn のテストベクタでも報告されています。

新しいプラットフォームを足すには​

  1. PlatformAttestationVerifier を実装する(3つの束縛 + チェーン検証)
  2. META-INF/services/org.idp.server.core.openid.clientinstance.registration.PlatformAttestationVerifier に追加する
  3. クライアント設定 client_instance_platform_config.<platform> に必要な項目を足す
  4. 外部ライブラリが要るなら platform 側にラッパーを作る

iOS App Attest を足す場合、束縛②の作り方が Android と異なります。Android は「鍵そのもの」の証明書チェーンなのでリーフの公開鍵と比較すれば済みますが、App Attest の attested key は App Attest 専用鍵で、登録したい鍵とは別物です。clientDataHash に登録鍵を含める形で間接的に縛る必要があります。


テスト​

テスト対象
AttestJwtClientAuthAuthenticatorTest認証フェーズ
RegisteredInstanceKeyModeTest自己署名モードの追加検証
AndroidKeyAttestationVerifierTest登録フェーズの3つの束縛・チェーン・レベル・鍵の性質
e2e/src/tests/spec/oauth_attestation_based_client_auth.test.jsdraft-11 の章立てに沿った準拠台帳

AndroidAttestationFixture が BouncyCastle でチェーンを合成するので、実機なしで検証ロジックを動かせます。hardwareEnforced も実機が埋めるとおりに埋めます。verifier がそこから読む値を、空のリストを持つフィックスチャでは動かせないためです(ルート証明書に CA 制約を持たせているのと同じ理由)。

一番効くテスト

rejectsAChainThatDoesNotLeadToTheConfiguredRoot — 攻撃者が自分のルートで作った、内部的には完璧なチェーンを拒否できることを固定しています。これが通らないと他の検証がすべて飾りになります。


一次ドキュメント​

用途参照先
ABCA 仕様draft-ietf-oauth-attestation-based-client-auth
Android 証明の ASN.1 スキーマ(規範)Key and ID attestation — AOSP
Android 証明の検証手順・ルート証明書Verify hardware-backed key pairs with key attestation
ルート証明書の取得https://android.googleapis.com/attestation/root
参照実装(Java)platform/external/android-key-attestation
参照実装(Kotlin)android/keyattestation

現時点の制約​

項目状態
証明書の失効確認未実装(#1890)
Play Integrity見送り。改造 OS の検出は Key Attestation の rootOfTrust で行う
PoP のリプレイ検出jti は存在確認のみ。使用済みの記録は持ちません

次のステップ​