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

Attestation-Based Client Authentication


前提知識​


概要​

attest_jwt_client_auth は、シークレットを配布せずにネイティブアプリを認証するクライアント認証方式です(draft-ietf-oauth-attestation-based-client-auth-11)。

モバイルアプリは配布物であり、埋め込んだシークレットは取り出せます。そのため従来は Public Client(none)として扱うしかありませんでした。この方式は、アプリのインスタンスごとに端末内で生成した鍵(Client Instance Key)で認証します。鍵は端末のセキュアハードウェアから出ないため、アプリを複製しても他の端末では使えません。

認証は2つの JWT をヘッダで送ることで行います。

ヘッダJWT署名する鍵主張する内容
OAuth-Client-AttestationClient Attestation JWTClient Attester の鍵、または登録済み Client Instance Keyこの client_id のインスタンスがこの公開鍵を持っている
OAuth-Client-Attestation-PoPClient Attestation PoP JWTClient Instance Keyその鍵をいま保持している

前者が「鍵の持ち主は正当なアプリである」、後者が「その鍵で今このリクエストを出している」を担当します。片方だけでは成立しません。


シーケンス​

Attestation JWT に誰が署名するかで、流れが 2 つに分かれます(どちらを選ぶかは信頼モデル)。

形client_attestation_trust_sourceAttestation JWT の署名者認可サーバーへのインスタンス登録
自己署名registered_instance_keyアプリ自身必要
Attester をバックエンドに持つattester_jwks / x5cアプリ提供者のバックエンド(Client Attester)不要

自己署名(registered_instance_key)​

インストールから最初のトークン取得まで。

Android Key Attestation では、証明書チェーンは鍵の生成時に得られます(手順 3 と 7〜8 が一度に済みます)。どちらの場合も、鍵は challenge を受け取ってから作ります。証明に challenge を埋め込むためです。

Attester をバックエンドに持つ(attester_jwks / x5c)​

アプリ提供者が Client Attester(バックエンド)を運用する形です。端末とアプリが本物かを確かめるのは Attester で、認可サーバーは Attester の署名を信頼します。認可サーバーへのインスタンス登録はありません。

  • アプリと Attester のやりとり(証明の送り方、チャレンジの出し方)は ABCA の範囲外で、アプリ提供者が決めます。Attester が発行した使い捨てのチャレンジを証明に含めさせ、過去の証明の使い回しを防ぐのが一般的です。
  • Attestation JWT の期限が切れたら、アプリは Attester から取り直します。期限切れのまま送ると use_fresh_attestation が返ります。
  • 特定のインスタンスを止める手段は、Attestation JWT の有効期限だけです。 インスタンスは認可サーバーに登録されていないので、認可サーバーからは止められません。Attester が次の Attestation JWT を出さなければ、期限切れで止まります。idp-server はこの形では有効期限に上限を掛けない(自己署名は 24 時間まで)ので、Attester 側で短くしてください。
  • 利用者への束縛はありません。Attestation JWT が保証するのは「本物のアプリの、この鍵」までで、誰が使っているかはログインで決まります。

Challenge を必須にしている場合は、リクエスト前に POST /{tenant-id}/v1/client-attestation/challenges で取得した値を PoP JWT の challenge クレームに入れます。


エンドポイント体系​

クライアント(アプリ)側​

エンドポイント用途
POST /{tenant-id}/v1/client-instances/challengesインスタンス登録用チャレンジの取得。{ client_id } を送り、{ challenge, instance_id } を受け取る
POST /{tenant-id}/v1/client-instancesClient Instance Key の登録。{ challenge, id_token, client_instance_public_key, platform_evidence }
POST /{tenant-id}/v1/client-attestation/challengesPoP 用チャレンジの取得。レスポンスの attestation_challenge を PoP JWT に入れる

登録系の2つはクライアント認証を要求しません(インストール直後で、まだ認証に使う鍵が無いため)。チャレンジ取得は無認証、登録は ID トークン(誰か)とプラットフォーム証明(どの端末・鍵か)で認証されます。詳しくは Client Instance 登録の認証。

なお2つのヘッダは、リクエストごとにそれぞれ厳密に1個です。同じヘッダを複数送ると拒否されます。

クライアント認証を行うエンドポイント​

2つのヘッダは、クライアント認証が発生する経路すべてで受け付けます。

エンドポイント
トークンPOST /{tenant-id}/v1/tokens
Pushed Authorization RequestPOST /{tenant-id}/v1/authorizations/push
CIBA backchannel authenticationPOST /{tenant-id}/v1/backchannel/authentications
IntrospectionPOST /{tenant-id}/v1/tokens/introspection
Introspection(拡張)POST /{tenant-id}/v1/tokens/introspection-extensions
RevocationPOST /{tenant-id}/v1/tokens/revocation

管理API側​

エンドポイント用途
GET /v1/management/tenants/{tenant-id}/client-instancesテナント内のインスタンスの検索
POST /v1/management/tenants/{tenant-id}/client-instances登録(client_id はボディで指定)
GET|DELETE /v1/management/tenants/{tenant-id}/client-instances/{id}取得・削除
POST /v1/management/tenants/{tenant-id}/client-instances/{id}/revoke失効(→ 失効と削除)

上はシステムレベル(管理テナントのトークン)のパスです。組織の管理者は、同じ操作を組織レベルのパス /v1/management/organizations/{organization-id}/tenants/{tenant-id}/client-instances で使います。

どちらも専用権限 idp:client-instance:create / :read / :revoke / :delete で保護されています。詳細は Client Instance 管理API を参照してください。登録は、アプリからの登録を使わず運用側で鍵を登録する場合に使います。

インスタンスはクライアントの下ではなくテナントの直下に置いています。運用で手元にあるのは利用者・鍵・証明書のシリアルで、どのクライアントのものかは調べるまで分からないためです。id は UUID で、テナント内で 1 件に決まります。

検索条件(すべて任意、組み合わせ可):

パラメータ使う場面
client_idクライアントで絞る(client_id でも alias でも可)
user_id利用者の問い合わせ(その人の端末の一覧)
status / revocation_reason有効なもの、失効したものと理由(active / revoked、operator / superseded)
expired有効期限(expires_at)を過ぎているか(true / false)。期限切れは status が active のままでも認証できないので、今使えるものは status=active&expired=false
certificate_serial証明書の漏えい時に、その証明書を経由して登録したインスタンスを探す(attestation_evidence のチェーンのシリアル。大文字でも可)
platform証明の種類(android-key-attestation / ios-app-attest など)
instance_key_thumbprint鍵(RFC 7638 サムプリント)からインスタンスを引く
from / to登録日時の範囲
limit / offsetページング。レスポンスに total_count が付きます

取りうる値が決まっている条件(status、revocation_reason、UUID の user_id)に合わない値は 400 を返します。空の一覧を返すと「該当なし」と読み違えるためです。


2つの JWT の作り方​

Client Attestation JWT​

{
"typ": "oauth-client-attestation+jwt",
"alg": "ES256",
"kid": "<instance_id>"
}
.
{
"sub": "<client_id>",
"exp": 1735689600,
"iat": 1735686000,
"cnf": {
"jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }
}
}
項目要件
typoauth-client-attestation+jwt 固定
algclient_attestation_signing_alg_values_supported に含まれる値。none と MAC 系(HS*)は拒否されます
sub認証する client_id と一致すること
exp必須。期限内であること
cnf.jwkClient Instance Key の公開鍵。d などの秘密鍵成分を含めてはいけません
kidregistered_instance_key では必須。登録時に受け取った instance_id を入れます
registered_instance_key では kid が無いと必ず 401 になります

自己署名モードでは、認可サーバーは JOSE ヘッダの kid を instance_id として登録済みの鍵を引きます。kid が無いと鍵を解決できず、署名検証に到達する前に失敗します。

instance_id はチャレンジ取得(POST /{tenant-id}/v1/client-instances/challenges)のレスポンスで返る値です。登録時に受け取ったら保存し、以降すべての Client Attestation JWT の kid に入れてください。

偽の kid を入れても、選ばれた鍵で署名が検証できないため通りません。

attester_jwks では kid は鍵の選択に使われません(client_attestation_attester_jwks の鍵で検証します)。Attester の鍵を複数並べてローテーションする場合は、JWKS 側と Attestation JWT 側で kid を揃えてください。

PoP JWT の kid は見ていません。 PoP の検証鍵は Attestation JWT の cnf.jwk から決まるためです。

自己署名の場合は追加で、cnf.jwk が登録済みの鍵と一致すること(RFC 7638 thumbprint 比較)、exp - iat が 24時間以内であることが必要です。

Client Attestation JWT は有効期限まで使い回せます(draft-11 Section 10.2)。リクエストごとに作り直す必要があるのは PoP JWT の方だけです。attester_jwks では Attester への往復を有効期限のあいだ省けます。

Client Attestation PoP JWT​

{
"typ": "oauth-client-attestation-pop+jwt",
"alg": "ES256"
}
.
{
"aud": "<認可サーバーの issuer identifier>",
"jti": "<リクエストごとに一意>",
"iat": 1735686000,
"challenge": "<attestation_challenge>"
}
項目要件
署名鍵Attestation JWT の cnf.jwk に対応する秘密鍵
typoauth-client-attestation-pop+jwt 固定
aud認可サーバーの issuer identifier(テナントの issuer)
jti必須。リクエストごとに一意な値
iat必須。現在時刻から**±60 秒以内**であること(client_attestation_pop_acceptable_window_seconds で変更可)
challengeclient_attestation_challenge_required が有効なテナントでは必須
issdraft-11 は PoP JWT に定義していません。載せても §5.1 の「MAY contain other claims」として無視されます
exp同上。有効範囲は iat の窓が決めます
実装のポイント

PoP JWT はリクエストごとに新しく作ります。jti はリプレイ検出のための識別子で、iat の窓とあわせて PoP の有効範囲を絞ります。

受け付けた PoP の jti は、クライアントごとに iat の窓が閉じるまで記録され、同じ PoP JWT の 2 回目は invalid_client_attestation(client attestation pop jwt has already been used)で拒否されます(draft-11 Section 12.1)。記録は Redis に置くため、Redis を設定していない配備や Redis のエラー時は記録されず、iat の窓だけで判定します。


信頼モデル: 誰が Attestation JWT に署名するか​

draft-11 はClient Attester への信頼の確立を仕様の範囲外としています(Section 10.8「Trust Management and Key Resolution」)。ただし同じ節で取りうる形は示しており、attester_jwks と x5c はそのうちの 2 つです。idp-server はクライアント設定 client_attestation_trust_source で切り替えます。

attester_jwks(既定)x5cregistered_instance_key
Attestation JWT の署名者Client AttesterClient AttesterClient Instance(自己署名)
認可サーバーが信頼する鍵client_attestation_attester_jwks に登録した公開鍵x5c のチェーンを client_attestation_trusted_root_certificates のトラストアンカーまで検証したリーフ事前登録した Client Instance Key
インスタンスの事前登録不要不要必要
Client Attester の運用必要必要不要
「正当なアプリか」の判断Attester がプラットフォーム証明を検証同左登録時のみ。以降は鍵の所持が根拠
Attester の鍵交代全クライアント設定の更新が必要トラストアンカーが変わらなければ設定変更不要該当なし

どちらを選ぶか​

アプリ提供者がサーバーを持っているなら attester_jwks か x5c。App Attest / Play Integrity の検証を Attester 側に集約でき、認可サーバーはプラットフォームごとの差異を知らずに済みます。アプリが複数の認可サーバーに接続する場合も、Attestation JWT を1か所で発行できます。

そのうえで、証明書の階層を持っているなら x5c。違いは鍵交代を誰が負担するかです。attester_jwks は Attester が署名鍵を替えるたびに、その Attester を信頼している全クライアント設定を更新して回る必要があります。x5c ならトラストアンカーを1つ登録しておけば、リーフの交代は認可サーバー側の設定変更なしに吸収されます。

Attester を運用しないなら registered_instance_key。認可サーバーへの登録が信頼の起点になるため、登録経路の強度がそのまま全体の強度になります。アプリからの登録は ID トークンで認証し、インスタンスを利用者に束縛します(client_instance_registration_policy: user_bound)。

1 つのインスタンスを複数の利用者が使う端末(窓口の端末など)や、Attester が別の事業者であるウォレット型のクライアントは、利用者に束縛できないため registered_instance_key の対象外です。attester_jwks か x5c を使います。ウォレットについては、HAIP がインスタンスに固有の識別子を持ち込まないことを求めている点も理由です(registered_instance_key は kid にインスタンスの ID を入れるため、インスタンスごとに固有の値が認可サーバーに渡ります)。

x5c の設定​

"extension": {
"client_attestation_trust_source": "x5c",
"client_attestation_trusted_root_certificates": ["<トラストアンカーの証明書 DER の base64>"]
}

設定する証明書はトラストアンカー(信頼の起点)です。項目名は root ですが、ルートである必要はなく、途中の CA でも構いません(RFC 5280 6 章: "The selection of a trust anchor is a matter of policy")。認可サーバーは、x5c のリーフから設定した証明書が発行した証明書までをパスとして検証し、それより上は見ません。

ピン留めするのは Attester の証明書(リーフ)ではなく、それを発行する CA です。CA がリーフより長生きすることが、この方式の利点の前提だからです。ルートと途中の CA のどちらを置くかは、そのルートが Attester 以外にも証明書を発行しているかで決めます。発行しているなら、Attester 専用の発行 CA を置いてください。

トラストアンカーには、そのクライアントの Attester のものだけを入れる

認可サーバーが確かめるのは「チェーンが設定したトラストアンカーまで辿れること」と「sub がこのクライアントの client_id であること」で、リーフの証明書が誰のものかは見ません。そのため、設定したトラストアンカーの下で証明書を持つ者なら誰でも、このクライアントの Client Attestation JWT を作れます。

  • 設定するのは、このクライアントの Attester のトラストアンカーだけにしてください。EUDI Wallet では、Wallet Provider ごとのトラストアンカーが Wallet Provider 用の Trusted List に載っており、Wallet Provider はリーフの署名用証明書から判断されます。このクライアントの Wallet Provider のエントリを設定します
  • Trusted List の全提供者のトラストアンカーや、複数の提供者の上位にある CA を設定しないでください。別の提供者が、自分の正規の証明書で sub にこのクライアントの client_id を入れて署名すれば、チェーンは設定したトラストアンカーまで辿れて認証が通ります
  • ルートが Attester 以外の用途の証明書も発行しているなら、ルートではなく Attester の証明書を発行する CA を設定してください。ルートを設定すると、ほかの用途の証明書の鍵でも Client Attestation JWT を作れます
  • 1 つの Attester が複数のクライアント(ウォレットの種類ごとの client_id など)を持つ場合、同じトラストアンカーを共有するため、その Attester は自分の別のクライアントも名乗れます

チェーンは信頼できない入力です。トラストアンカーまでの検証だけが意味を与えます — 単にパースできるチェーンは送ってきた者が書いたもので、そのリーフ鍵は自分の署名を検証できてしまいます。したがって検証に失敗したチェーンからは鍵を返さず、認証は「信頼できる鍵が無い」として失敗します。

トラストアンカーはチェーンに含めても含めなくても動きます

HAIP は x5c にトラストアンカーを含めてはならないとしています。認可サーバーはトラストアンカーを設定から持っているため、トラストアンカーを含まないチェーンでも、含むチェーンでも、さらにその上のルートまで含むチェーンでも検証できます。トラストアンカーより上の証明書は読みません(有効期限も見ません)。

x5c のリーフはトラストアンカーそのものであってはならず、CA でない証明書(end entity)である必要があります。

x5c では JWKS に alg を書く必要がありません

attester_jwks では、JWKS の鍵に kid も alg も無いと署名検証に到達する前に 401 になります(kid が無ければ alg で鍵を引くため)。x5c では証明書から鍵を取り出す際に、提示された alg をその鍵に付けてから返すので、この落とし穴がありません。

2つの設定の関係​

client_instance_registration_policy が効くのは、registered_instance_key を選んでインスタンス登録を行うときだけです。attester_jwks では登録した鍵が認証時に参照されないため、設定しても効きません。

 attester_jwks / x5c
└ 登録エンドポイントを使わない
→ client_instance_registration_policy は不要

registered_instance_key
├ アプリから登録させる
│ └ user_bound … ID トークンで登録を認証し、インスタンスを利用者に束縛する
└ 運用側が管理APIで登録する
→ policy は参照されない
登録経路policy挙動
アプリからuser_boundID トークンとプラットフォーム証明を検証し、ID トークンの利用者に束縛する
アプリから未設定・未知の値登録を拒否します
管理APIから何でもpolicy に関係なく登録できます(利用者には束縛されません)

Client Instance 登録の認証​

アプリからの登録は、2 つのもので認証します。

何で何を示すか
ID トークン誰がログインしたか(インスタンスを束縛する利用者)
プラットフォーム証明どの端末の、どのアプリの、どの鍵か

2 つを結びつけるのが request_hash です。

request_hash = base64url_nopad( SHA-256( challenge_bytes || canonical_jwk_utf8 ) )
canonical_jwk = RFC 7638 thumbprint の入力(必須メンバのみ・辞書順・空白なし)

アプリはこの値を認可リクエストの nonce に指定してログインします。返ってくる ID トークンの nonce は、このチャレンジとこの鍵にしか合いません。

{
"challenge": "<チャレンジ取得で得た値>",
"id_token": "<nonce に request_hash を指定して取得した ID トークン>",
"client_instance_public_key": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." },
"platform_evidence": {
"platform": "<プラットフォーム識別子>",
"...": "<プラットフォームごとの証明>"
}
}
nonce に鍵まで含める理由

nonce がチャレンジだけだと、漏れた ID トークンと攻撃者自身の本物の端末の証明を組み合わせて、被害者の利用者に攻撃者の鍵を束縛できてしまいます。鍵を含めた request_hash なら、被害者の端末の中にある鍵が無い限り一致しません。

1 人の利用者に有効なインスタンスは 1 つ​

登録が成功すると、同じ利用者の同じクライアントの、他の有効なインスタンスを失効させます。失効と新しいインスタンスの登録は同じトランザクションで行います。

  • 端末が同じかどうかは判定しません。インスタンスは端末ではなく、鍵の登録の単位です。アプリを入れ直しても、2 台目の端末に入れても、前のインスタンスは失効します
  • 失効したインスタンスは revocation_reason: superseded として残り、失効したインスタンスごとにセキュリティイベント client_instance_revoked(superseded_by に新しいインスタンス)を出します。利用者への通知に使えます
  • 旧い端末のアプリは次のリクエストで 401 invalid_client_attestation を受け取ります。登録し直すには鍵を作り直してログインからやり直します(失効した鍵は再登録できません)。そうすると今度は新しい端末の方が失効します
  • 置き換えられたインスタンスのトークンは削除しません。 失効した時点で旧い端末は次のクライアント認証(リフレッシュを含む)で止まります。トークンまで消すのは「他の端末からログアウトさせる」操作で、端末の紛失や盗難のときに管理API の失効・削除で明示的に行います(→ インスタンスに発行したトークン)。アクセストークンは有効期限まで使えます
  • 同じ利用者の登録が同時に 2 件走っても、有効なインスタンスが 2 つ残ることはありません。データベースの一意制約で後の方が失敗します

1 つの鍵は 1 つのインスタンスだけ​

同じテナントの中では、1 つの鍵を登録できるインスタンスは 1 つだけです。クライアントが違っても、失効したインスタンスの鍵でも、同じ鍵はもう一度登録できません(削除したインスタンスの鍵は除きます)。アプリからの登録でも管理API からの登録でも同じです。

リフレッシュトークンは鍵に束縛されます。同じ鍵が 2 つのインスタンスに載ると、片方を失効させても鍵がもう片方の経路で使え続けてしまうためです。範囲をテナントに留めているのは、テナントをまたぐ制約にすると、あるテナントの登録が別のテナントの鍵の有無を明かしたり、妨げたりできてしまうためです。

ID トークンの検証​

#検証
1この認可サーバーが署名した JWS であること(暗号化された ID トークンは受け付けません)
2iss がこの認可サーバーで、exp を過ぎていないこと
3aud が登録先のクライアント、またはその client_instance_registration_clients に載ったクライアントであること
4nonce が、チャレンジと client_instance_public_key から計算した request_hash と一致すること
5iat がチャレンジの発行より後であること(この登録のためのログインであること)
6sub の利用者が存在し、有効であること

ID トークンの取り方​

ID トークンはクライアント認証なしで取得する必要があります。登録が済むまで、アプリはクライアント認証に使う鍵を持たないからです。

経路取得のしかた使いどころ
同じクライアントハイブリッドフロー(response_type=code id_token)。認可応答で ID トークンとコードが同時に返る。登録後、同じコードを登録した鍵の attest_jwt_client_auth で交換する基本。ログイン 1 回で登録とトークン取得が済む
登録用クライアント登録先クライアントの client_instance_registration_clients に載せたパブリッククライアントでログインするPAR 必須のテナント。PAR にはクライアント認証が要るため、登録先クライアント自身では ID トークンを取れない
"extension": {
"client_attestation_trust_source": "registered_instance_key",
"client_instance_registration_policy": "user_bound",
"client_instance_registration_clients": ["<登録用パブリッククライアントの client_id>"]
}
登録用クライアントは明示したものだけ

client_instance_registration_clients に載っていないクライアントの ID トークンは拒否します。載せていないクライアント(利用者がログインしただけの第三者アプリなど)の ID トークンで、登録先クライアントのインスタンスを登録できないようにするためです。

登録用クライアントには、登録までに必要なスコープだけを与えてください(通常は openid)。登録用クライアントはパブリッククライアントで、そのトークンはどの端末・アプリから来たかを保証しません。eKYC など利用者の操作は、登録後に ABCA で認証する登録先クライアントで行うのが基本です。登録前に必要な場合だけ、その操作に要るスコープに限って与えます。

パブリッククライアントを置けないテナント(FAPI 2.0 など)では、どちらの経路も使えません。

request_hash の計算で間違えやすいところ
  1. challenge を base64url デコードせず文字列のまま連結している
  2. JWK のキー順が辞書順でない
  3. JSON に空白が入っている
  4. alg / use / kid を含めてしまっている(必須メンバのみ)

固定ベクタ: challenge=Zm9vYmFyLWNoYWxsZW5nZS0wMQ → request_hash=YY-nDEK6JHQLVe893qieCiyyQ2kW5fBmIPNlVdflj1I

プラットフォーム証明の検証​

platform_evidence を検証するのは PlatformAttestationVerifier の実装で、プラットフォームごとにモジュールが提供します。実装が1つも登録されていなければ、登録はすべて拒否されます(安全側の既定)。

Android Key Attestation の検証は次の順で行います。

Android Key Attestation の検証

証明書チェーンは攻撃者が自由に作れる入力なので、ピン留めしたルートまで検証しない限り以降の判定は意味を持ちません。攻撃者は自分で拡張を書けるため、チャレンジもアプリ名も望みどおりに入れられます。

リンクの署名が繋がっているだけでは足りず、発行者の位置に現れる証明書が実際に発行権限を持つか(BasicConstraints cA=TRUE / pathLenConstraint / KeyUsage keyCertSign)まで確認します。署名鍵は署名する相手を選ばないため、末端証明書の鍵でも別の証明書を作れてしまうからです。

検査順序とプラットフォーム別の差は 証明書チェーンの検証、考え方は 証明書チェーンをどこまで信じるか を参照。

設定はクライアントの client_instance_platform_config に置きます。

"client_instance_platform_config": {
"android_key_attestation": {
"package_names": ["com.example.wallet"],
"signature_digests": ["<署名証明書の SHA-256(base64url)>"],
"min_security_level": "trusted_environment"
}
}
フィールド既定内容
package_names必須許可するパッケージ名
signature_digests必須署名証明書のダイジェスト。提示された値がすべてここに含まれることが条件
min_security_leveltrusted_environmenttrusted_environment / strong_box。software は常に拒否。attestationSecurityLevel と keyMintSecurityLevel の両方に適用されます
verified_boot_states["verified"]受け入れる起動状態。verified / self_signed / unverified。failed は指定できない
require_device_lockedtrueブートローダーがロックされていることを要求する
min_os_patch_levelなしOS のセキュリティパッチの下限(YYYYMM の数値。例: 202406)
challenge_bindingchallengeattestationChallenge に埋め込む値(プラットフォーム証明に埋め込む値)
override_root_certificates—同梱の Google ルートを置き換えるルート(テスト用)。設定すると同梱のルートでは検証せず、そのルートの持ち主が作った証明だけが通ります。設定すると WARN ログが出ます

signature_digests が必須なのは、パッケージ名が秘密ではないためです。攻撃者は自分の端末で同じパッケージ名のアプリを名乗れるので、再署名を見分けるのは署名証明書のダイジェストだけです。

設定できない、常に要求する条件が 2 つあります。

条件無いと通ってしまうもの
origin が KM_ORIGIN_GENERATED外部で生成して取り込んだ鍵。セキュアハードウェア内にあっても生成元に複製が存在するため、所持を示しても「どの端末か」を示せない。KM_ORIGIN_SECURELY_IMPORTED も同様に拒否します(ラップした側は平文を持っていたため)
purpose が KM_PURPOSE_SIGN を含む署名に使えない鍵。登録は通り、最初の PoP で署名検証に落ちる

起動状態も要求します。 パッケージ名と署名ダイジェスト(attestationApplicationId)を書くのは Android プラットフォームで、KeyMint ではありません。改造した OS はどのアプリの名前でも名乗れます。KeyMint がブートローダーの計測から書く rootOfTrust(verifiedBootState と deviceLocked)で、正規に検証された OS が起動し、ブートローダーがロックされていることを確かめて初めて、アプリの名乗りを信用できます。既定では verified かつロック済みだけを受け入れます。開発端末やカスタム OS(self_signed)を通したい配備は、verified_boot_states と require_device_locked で明示的に緩めます。

これで確かめているのは「正規の OS が正規の手順で起動した」ことで、「root 化されていない」ことの保証ではありません。ブートローダーのアンロックを前提とする root 化(改造した boot イメージを書き込む方式)やカスタム OS は弾けますが、ロックされたまま OS の脆弱性で一時的に root を取るものは、起動時の状態に現れないため弾けません。iOS の App Attest には、脱獄の有無を示す項目がありません。

鍵の性質(origin / purpose)も起動状態(rootOfTrust / osPatchLevel)も、hardwareEnforced 側の AuthorizationList から読みます。鍵自身の性質を知っているのは KeyMint だけで、softwareEnforced に同じ値があっても、それはプラットフォームの申告にすぎないためです。端末が報告しなかった場合も拒否します(判定の材料が無いことは、条件を満たす証拠にはなりません)。

iOS App Attest​

"client_instance_platform_config": {
"ios_app_attest": {
"app_ids": ["ABCDE12345.com.example.wallet"],
"environment": "production"
}
}
フィールド既定内容
app_ids必須許可する App ID(Team ID.Bundle ID)
environmentproductionproduction / development
challenge_bindingrequest_hashrequest_hash だけを受け付けます(下記)
override_root_certificates—同梱の Apple ルートを置き換えるルート(テスト用)。設定すると WARN ログが出ます

インスタンス鍵は App Attest の鍵とは別の鍵です。 App Attest の秘密鍵にアプリができる操作は generateAssertion(authenticatorData ‖ clientDataHash への署名)だけで、任意の JWS には署名できません。そのため、App Attest の鍵では Client Attestation JWT も PoP JWT も作れません。アプリはインスタンス鍵を別に作り(CryptoKit の SecureEnclave.P256.Signing.PrivateKey を推奨)、clientDataHash に request_hash(チャレンジとインスタンス鍵の SHA-256)を渡して App Attest の鍵を attest します。

1. チャレンジを取得し、インスタンス鍵を生成(Secure Enclave)
2. request_hash = SHA-256(チャレンジのバイト列 ‖ インスタンス鍵の canonical JWK)
3. DCAppAttestService.generateKey() → attestKey(keyId, clientDataHash = request_hash)
4. 登録: client_instance_public_key = インスタンス鍵、platform_evidence = attestation object

認可サーバーは、証明書の nonce が SHA-256(authenticatorData ‖ request_hash) と一致することで、「この登録で、このインスタンス鍵の登録を、正規のアプリが要求した」ことを確かめます。証明書が証明する公開鍵(App Attest の鍵)と client_instance_public_key は一致しません。

プラットフォーム証明に埋め込む値​

アプリは登録用のチャレンジをプラットフォーム証明に埋め込みます。Android は attestationChallenge を「任意のバイト列」、Apple は clientDataHash を「サーバーが送る一回限りのチャレンジの SHA-256」としか定めておらず、チャレンジが文字列として届いたとき何を埋め込むかはクライアントの実装によって分かれます。ずれると nonce が一致しないとしか分からず、登録エンドポイントは理由を返さないため、切り分けが困難です。

そこで、埋め込む値をクライアントごとに challenge_binding で選びます(android_key_attestation と ios_app_attest のそれぞれに置きます)。自由な組み立て方ではなく、次の 3 つからの選択です。どれを選んでも、証明はこの登録のチャレンジに結びつきます。

iOS は request_hash だけです(既定も request_hash)。iOS ではインスタンス鍵が App Attest の鍵と別なので(iOS App Attest)、インスタンス鍵を証明に結びつける手段は clientDataHash しかありません。鍵を含むのは request_hash だけで、他の値ではどの鍵の登録かを証明が何も言わなくなるため、クライアント設定の時点で拒否します。

値Android attestationChallengeiOS clientDataHash
challenge(Android の既定)チャレンジを base64url デコードしたバイト列指定できない
challenge_textチャレンジの文字列の UTF-8指定できない
request_hash(iOS の既定)request_hash を base64url デコードした 32 バイトrequest_hash を base64url デコードした 32 バイト(それ自体が SHA-256 なので、もう一度はハッシュしない)

request_hash は ID トークンの nonce と同じ値(チャレンジと鍵の SHA-256)です。Android では鍵の一致を証明書の公開鍵で別に確かめているので、強さは 3 つとも変わりません。

固定ベクタ(challenge = Zm9vYmFyLWNoYWxsZW5nZS0wMQ、鍵は x = VcKVNBZ4IaBAYW3jxM4w3TJFVA7myeUGQyGt-g_yvpQ, y = f-E-hYE3TAWKwhVv9pej9NABs9SX9XsNO80x57jFTyU の P-256。いずれも 16 進)

値Android attestationChallengeiOS clientDataHash
challenge666f6f6261722d6368616c6c656e67652d3031—
challenge_text5a6d3976596d46794c574e6f595778735a57356e5a5330774d51—
request_hash618fa70c42ba24740b55ef3ddea89e0a2cb2436916e5f06620f36555d7e58f52618fa70c42ba24740b55ef3ddea89e0a2cb2436916e5f06620f36555d7e58f52

iOS の nonce(証明書の拡張に入る値)は、ここからさらに SHA-256(authenticatorData ‖ clientDataHash) です。

登録時に残す証跡​

検証で確かめたことは、登録したインスタンスの attestation_evidence に残ります。管理API の Client Instance 一覧・取得で読めます。

{
"platform": "android-key-attestation",
"verified_at": "2026-09-24T12:00:00.000",
"key": {
"attestation_security_level": "strong_box",
"keymint_security_level": "strong_box",
"origin": "generated"
},
"app": { "package_names": ["com.example.wallet"], "signature_digests": ["..."] },
"device": { "verified_boot_state": "verified", "device_locked": true, "os_patch_level": 202409 },
"chain": {
"certificates": [
{ "serial": "1", "not_after": "2036-01-01T00:00:00Z", "sha256": "<証明書の SHA-256(16進)>" }
]
}
}

iOS App Attest では key と device を持たず、app が { "app_id": "...", "environment": "production" } になります。

残す目的は 3 つです。

目的使う項目
まとめて失効するchain.certificates の serial。提示されたチェーンの証明書をすべて、Google の失効リストと同じ形(小文字の 16 進)で残すので、漏洩した鍵や中間証明書に連なるインスタンスを後から探せる
監査key(セキュリティレベル、生成元)、app(一致したアプリ)
方針を厳しくしたときの再評価key。たとえば StrongBox を必須にしたとき、対象のインスタンスを引ける

チェーンそのものと、端末を一意に識別する値は残しません。


Client Instance の失効と削除​

インスタンスを止める操作と、消す操作は別です。

失効(POST .../client-instances/{id}/revoke)削除(DELETE .../client-instances/{id})
意味信頼をやめる記録を消す
インスタンスの記録残る(status: revoked、revoked_at、revocation_reason、登録時の証跡)消える
その鍵での認証401 invalid_client_attestation401 invalid_client_attestation
発行済みのトークン削除削除
同じ鍵の再登録できないできる(別のインスタンスになり、旧いトークンは引き継がない)
使う場面端末の紛失、鍵の漏えい、不正の発覚誤って登録したものの片付け、保持期間を過ぎた記録の整理

端末を止めたいときは失効を使います。記録が残るので、誰のどの端末を、いつ、なぜ止めたかを後から追えます。revocation_reason は、管理API からの失効が operator、同じ利用者の新しい登録に置き換わった失効が superseded です。

利用者を削除すると、その利用者のインスタンスは全クライアント分が削除されます。トークンも利用者の削除で消えます。

失効は元に戻せません。 失効したインスタンスを有効に戻す操作はありません。すでに失効しているインスタンスをもう一度失効させようとすると 400 を返し、最初の revoked_at を保ちます。見つかった端末をもう一度信頼するときは、アプリが新しい鍵を作り、ログインから登録し直します。鍵が手元を離れていたあいだに何があったかは分からないため、ログインとプラットフォーム証明で確かめ直すところから始めます。

インスタンスに発行したトークン​

registered_instance_key で発行したトークンは、鍵(draft-11 Section 10.3)に加えてインスタンスに束縛されます。

  • リフレッシュは同じインスタンスから。 同じ鍵でも、削除後に登録し直したインスタンスは別物として扱い、invalid_grant を返します
  • 利用者に束縛したインスタンスは、その利用者のトークンだけを受け取ります。 別の利用者の認可コード・CIBA の auth_req_id・リフレッシュトークンは invalid_grant です
  • client_instance_registration_policy: user_bound のクライアントは client_credentials を使えません(400 unauthorized_client)。利用者のいるグラントは、ログイン・リフレッシュのたびに利用者が有効かを確かめますが、client_credentials のトークンには利用者がいないため、利用者を削除・無効化しても端末がトークンを取り続けられてしまうからです。グラントの可否は他のグラントと同じくクライアント単位で決まり、管理API から登録した(利用者の無い)インスタンスも同じ扱いです。利用者のいない端末やサーバーで client_credentials が必要なら、別のクライアントにします。ABCA の仕様の要件ではなく idp-server の方針です。先行事例も同じ方向で、IT-Wallet の Credential Issuer はトークンエンドポイントのグラントを authorization_code と refresh_token に限り、EUDI の PID Issuer は Attestation で認証するクライアントの client_credentials を無効にし、バックエンド用の別クライアントにだけ許しています
  • 管理API で失効・削除するとトークンも削除します。 リフレッシュトークンと、introspection で確かめるアクセストークンはその時点で止まります。新しい登録で置き換えられた(superseded)インスタンスのトークンは削除しません。JWT をリソースサーバーが手元で検証する場合は有効期限まで通るため、即座に止めたいならアクセストークンの有効期限を短くするか introspection を使います

有効期限​

インスタンスは既定では期限を持たず、失効か、同じ利用者の新しい登録で置き換わるまで使えます。クライアントに client_instance_lifetime_seconds を設定すると、アプリからの登録時に expires_at が入ります(管理API からの登録では expires_at を直接指定します)。

期限を過ぎたインスタンスは ABCA で認証できなくなり(invalid_client_attestation)、インスタンスに束縛したリフレッシュトークンも更新できなくなります。アプリは登録し直すしかなく、それには利用者のログインと、新しい鍵でのプラットフォーム証明が要ります(Android はチャレンジを鍵の生成時に埋め込み、iOS は 1 つの鍵を 1 回しか証明できないため、同じ鍵では作り直せません)。新しい登録で、期限切れのインスタンスは superseded として失効します。

期限を設ける意味は、端末の状態(OS のパッチレベルなど)を新しい証明で確かめ直すこと、方針を厳しくしたときに既存のインスタンスも入れ替わることです。

期間はリフレッシュトークンの寿命以上に揃えます。 refresh_token_strategy を固定(最初の発行から数える)にしている配備では、利用者はリフレッシュトークンの寿命ごとにログインし直します。インスタンスの期限をそれに重ねれば、ハイブリッドフローのログイン 1 回で登録し直しとトークンの取得が済み、利用者の負担はほとんど増えません。リフレッシュトークンより短くすると、ログインの回数がそのぶん増えます。


Challenge​

PoP JWT の challenge クレームは、その PoP がこの認可サーバー向けに作られたことを示します(draft-11 Section 7.2 item 5)。

有効化​

設定既定意味
client_attestation_challenge_requiredfalsechallenge を必須にするか
client_attestation_challenge_duration300(秒)発行した Challenge の有効期間

エンドポイントの公開と強制を分けてあるため、先にエンドポイントだけ公開してクライアントの対応を待ち、揃ってから必須化する移行ができます。

Challenge は単回消費ではありません。 有効期間のあいだ何度でも使えます。CIBA のポーリングのように短時間に何度もリクエストする経路で、そのつど取得し直さずに済むようにするためです。既定の 300 秒は backchannel_authentication_request_expires_in に合わせてあります。PoP JWT 自体のリプレイは、jti の記録で防いでいます(Challenge の寿命とは関係しません)。

必須化後に Challenge が無いとき​

400 Bad Request で次のエラーが返ります(draft-11 Section 6.1)。

{
"error": "use_attestation_challenge",
"error_description": "..."
}

このエラーには新しく発行された Challenge が同梱されます(Section 6.1 / 7.4)。レスポンスヘッダ OAuth-Client-Attestation-Challenge に載るため、失敗したリクエストがそのまま次の Challenge の受け渡しを兼ねます。クライアントは Challenge エンドポイントを別途叩かずに、その値で PoP JWT を作り直して再送できます。


設定​

クライアント​

フィールド必須内容
token_endpoint_auth_method✅attest_jwt_client_auth
client_attestation_trust_source✅attester_jwks / x5c / registered_instance_key
client_attestation_attester_jwksattester_jwks 時信頼する Client Attester の公開鍵(JWK Set)。秘密鍵・共通鍵を含めてはならない
client_instance_registration_policy登録エンドポイント使用時user_bound
client_instance_registration_clients—登録を認証する ID トークンを受け付ける、ほかのクライアントの client_id。自分自身の ID トークンは常に受け付ける
client_instance_lifetime_seconds—アプリが登録したインスタンスの有効期間(秒)。未設定または 0 なら期限なし(有効期限)

認可サーバー​

フィールド既定内容
client_attestation_signing_alg_values_supported—Attestation JWT に許可する alg
client_attestation_pop_signing_alg_values_supported—PoP JWT に許可する alg
client_attestation_challenge_requiredfalseChallenge の強制
client_attestation_challenge_duration300Challenge の有効期間(秒)
client_attestation_pop_acceptable_window_seconds60PoP JWT の iat と現在時刻の差の許容範囲(秒、前後とも。1〜600)。使用済み jti を記録しておく期間も決める

discovery(/.well-known/openid-configuration)には次が出力されます。クライアントはここから対応 alg とチャレンジエンドポイントを知ります。

キー内容
client_attestation_signing_alg_values_supportedAttestation JWT に許可する alg
client_attestation_pop_signing_alg_values_supportedPoP JWT に許可する alg
challenge_endpointチャレンジエンドポイントの URL

challenge_endpoint は広告するかどうかだけを決めます(userinfo_endpoint などと同じ扱い)。未設定でもエンドポイント自体は応答します。ただし §6.3 は、エンドポイントを提供するサーバーに challenge_endpoint の広告を求めています(MUST)。未設定のまま応答している状態はこれに沿いません。段階導入は client_attestation_challenge_required で行います。


エラー​

エラーコードHTTP意味クライアントの対応
invalid_client_attestation401Attestation JWT / PoP JWT の検証に失敗したJWT の内容を見直す。再送しても通らない
use_attestation_challenge400Challenge が必須だが含まれていない、または発行していない・期限切れの Challenge が含まれている同梱された Challenge で PoP を作り直して再送する
use_fresh_attestation401Attestation JWT が期限切れ、または有効期間が長すぎるAttestation JWT を取り直す(attester_jwks なら Attester へ、自己署名なら作り直す)

use_attestation_challenge だけが 400 なのは、draft-11 Section 6.1 が認可サーバーにそう定めているためです。ただしイントロスペクション(拡張エンドポイントを含む)と PAR はクライアント認証の失敗をすべて 400 で返すため、そこでは 3 つとも 400 です。

invalid_client ではなく専用コードを返すことで、「認証情報が違う」のか「Challenge を付ければ通る」のかを区別できます。


準拠状況​

準拠対象は draft-11(2026-09-03 公開、2026-09-08 から WG Last Call)です。draft-11 の章立てに沿った E2E 仕様準拠テストがあります。

テスト内容状況
e2e/src/tests/spec/oauth_attestation_based_client_auth.test.jsdraft-11 の要件52 実装 / 34 未対応(xit で列挙)
e2e/src/tests/spec/oauth_attestation_registered_instance_key.test.js自己署名モード10
e2e/src/tests/spec/oauth_attestation_x5c.test.js証明書チェーンモード7
e2e/src/tests/spec/oauth_attestation_instance_registration.test.jsインスタンス登録フロー17

draft-11 のうち、次は対応していません。

項目状況
DPoP 結合モード(attest_jwt_client_auth_dpop、§5.2 / §7.3)未対応。OAuth-Client-Attestation と DPoP があって PoP ヘッダが無いリクエストは、§7 のとおり invalid_client で拒否します
Challenge エンドポイントでの DPoP-Nonce 返却(§6.3)未対応。DPoP のサーバー発行 nonce 自体を持たないため、現状は該当しません
成功レスポンスでの Challenge 返却(§6.2、MAY)未対応。Challenge を返すのは use_attestation_challenge のエラー時だけです
クライアントメタデータ(§9)未対応。client_attestation_signing_alg_values_supported などをクライアント設定に持てません
追加のセキュリティシグナルとしての利用(§7.6)未対応
プロファイル(§13)未対応。typ / sub の読み替えやリフレッシュトークン束縛の変更は受け付けません

未対応の要件は削除せず xit で残してあるため、「カバー済み / 未対応 / 欠落」がテストファイルから読み取れます。

仕様準拠テストはリクエスト単位の台帳なので、運用の一周は別層で通しています。

テスト内容状況
e2e/src/tests/usecase/abca/abca-01-attester-jwks.test.jsAttester が JWKS を公開するモデルの一生(起動・CAJ 再利用・期限切れからの回復・Attester 鍵ローテーション)7
e2e/src/tests/usecase/abca/abca-02-client-instance-registration.test.js自己署名モデルの一生(初回登録・再インストール・端末紛失時の失効)。Challenge を強制したテナントで実行7

設定を実際に組んで動かす手順は ユースケーステンプレート にあります。


本番で確認すること​

項目確認すること
テスト用ルートの残りclient_instance_platform_config に override_root_certificates が入っていないこと。未設定なら同梱の Google / Apple のルートで検証する。設定すると同梱のルートが置き換わり、そのルートの持ち主が作った証明だけが通る(WARN ログが出る)
x5c のトラストアンカーclient_attestation_trusted_root_certificates に、Attester の証明書を発行する CA(ルートが Attester 専用でなければ、その下の発行 CA)をピン留めする
アクセストークンリソースサーバーが JWT を手元で検証するなら有効期限を短くするか introspection を使う。失効・削除でトークンを消しても、手元で検証する JWT は期限まで通る
ENCRYPTION_KEYChallenge の HMAC 鍵を兼ねる。変更すると発行済みの Challenge は通らなくなる(クライアントは use_attestation_challenge で取り直せる)
流量制御Challenge エンドポイントとインスタンス登録用のチャレンジは認証なしで呼べる。エッジで絞る(運用ガイダンス)

セキュリティ考慮事項​

  • Client Instance Key は端末のセキュアハードウェアに置く。 ソフトウェア保管では、アプリのコピーで認証が通ってしまい、この方式を採用する意味が薄れます
  • iOS ではインスタンス鍵の置き場所を App Attest は証明しない。 App Attest が示すのは「正規のアプリが、このインスタンス鍵の登録を要求した」ことまでです。インスタンス鍵が Secure Enclave にあるかはアプリの実装に委ねられます(Android は Key Attestation がインスタンス鍵そのものを証明するため、置き場所まで確かめられます)
  • attester_jwks では Client Attester の検証が信頼の起点。 認可サーバーはプラットフォーム証明を見ません。Attester が App Attest / Play Integrity を正しく検証していることが前提です
  • registered_instance_key は自己署名。 「正当なアプリか」の裏付けは登録時のみで、以降は鍵の所持だけが根拠です。登録経路の強度がそのまま全体の強度になります
  • PoP のリプレイ検出は Redis に依存する。 使用済みの jti は Redis に記録します。Redis を設定していない配備や Redis の障害中は記録されず、同じ PoP JWT が iat の窓(既定 60 秒)のあいだ再利用できます
  • Challenge は単回消費ではない。 有効期間内は再利用できます。サーバーは発行した Challenge を保存せず、テナントと有効期限を含む HMAC 付きの値として発行し、戻ってきた値が自身の発行したものかを照合します。インスタンス登録用のチャレンジは別物で、こちらは原子的に消費されます

関連仕様​


参考​