はじめに
OpenID for Verifiable Credential Issuance 1.0 (以降 OID4VCI) という仕様があります。これは Verifiable Credential (以降 VC) の発行に関する仕様で、最終版は 2025 年 9 月に承認されました (発表)。
| バージョン | 通称 | 承認年月 |
|---|---|---|
| 草稿第一版 | OID4VCI 1.0 ID1 | 2024 年 4 月 |
| 草稿第二版 | OID4VCI 1.0 ID2 | 2025 年 2 月 |
| 最終版 | OID4VCI 1.0 Final | 2025 年 9 月 |
本記事では、OID4VCI 1.0 の鍵証明 (Key Proof) への Proof Replay 攻撃とその対策について紹介します。
鍵証明
ウォレットは、クレデンシャルイシュアに VC の発行を依頼する際、公開鍵を渡す場合があります。
公開鍵を渡すのは、VC に公開鍵を埋め込んでもらうためです。
このとき、公開鍵をそのまま渡すだけであれば話は単純です。しかし、要件上、ウォレットは「公開鍵とペアになっている秘密鍵を持っている」ことを証明する必要があります。そのため、渡し方が複雑になります。
一般的に、何らかのデータに秘密鍵でデジタル署名を行うことで、秘密鍵を持っていることを証明できます。これについては、過去に『図解 X.509 証明書』という記事の『デジタル署名(前提知識)』で簡単に触れさせていただきました。
ウォレットがクレデンシャルイシュアに公開鍵を渡す際、このデジタル署名を応用します。
ウォレットは、公開鍵を含むデータに、その公開鍵とペアになっている秘密鍵で署名します。いわゆる、自己署名をおこないます。そして、その署名されたデータをクレデンシャルイシュアに渡します。クレデンシャルイシュアは、そのデータから公開鍵を取り出し、その公開鍵を用いて、データに付いている署名を検証します。検証がパスすれば、ウォレットが秘密鍵を持っていると言えます。
このように秘密鍵を持っていることを証明するデータを、OID4VCI 1.0 では鍵証明 (Key Proof) と呼んでいます。
鍵証明を実現する方法は幾つかあります。OID4VCI 仕様の Appendix F.1. jwt Proof Type では、JWT 形式の鍵証明の詳細を定義しています。下記は仕様に掲載されている JWT 形式の鍵証明の例です。
eyJ0eXAiOiJvcGVuaWQ0dmNpLXByb29mK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6IkVDIiwiY3J2IjoiUC0yNTYiLCJ4IjoiblVXQW9BdjNYWml0aDhFN2kxOU9kYXhPTFlGT3dNLVoyRXVNMDJUaXJUNCIsInkiOiJIc2tIVThCalVpMVU5WHFpN1N3bWo4Z3dBS18weGtjRGpFV183MVNvc0VZIn19.eyJhdWQiOiJodHRwczovL2NyZWRlbnRpYWwtaXNzdWVyLmV4YW1wbGUuY29tIiwiaWF0IjoxNzAxOTYwNDQ0LCJub25jZSI6IkxhclJHU2JtVVBZdFJZTzZCUTR5bjgifQ.-a3EDsxClUB4O3LeDD5DVGEnNMT01FCQW4P6-2-BNBqc_Zxf0Qw4CWayLEpqkAomlkLb9zioZoipdP-jvh1WlA
この鍵証明のヘッダとペイロードを base64url でデコードした内容は次のようになります。
{
"typ": "openid4vci-proof+jwt",
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "nUWAoAv3XZith8E7i19OdaxOLYFOwM-Z2EuM02TirT4",
"y": "HskHU8BjUi1U9Xqi7Swmj8gwAK_0xkcDjEW_71SosEY"
}
}
{
"aud": "https://credential-issuer.example.com",
"iat": 1701960444,
"nonce": "LarRGSbmUPYtRYO6BQ4yn8"
}
この例では、公開鍵はヘッダの jwk パラメータの値として埋め込まれています。
Proof Replay 攻撃とその対策
OID4VCI 1.0 の 13.8. Proof replay では、鍵証明を窃取した攻撃者がその鍵証明をリプレイする攻撃 — Proof Reply 攻撃 — について言及しています。
この攻撃の対策となるのが、鍵証明に埋め込む nonce です。クレデンシャルイシュアが発行する時間制限のある nonce を鍵証明に含めることで、結果として鍵証明に時間制限がかかることになり、リプレイ攻撃が難しくなります (攻撃可能な時間が短くなります)。
nonce をどのように鍵証明に含めるかは鍵証明の形式によります。例えば、JWT 形式の鍵証明であれば、nonce は nonce というクレーム名でペイロードに埋め込まれます。前のセクションに掲載した例に nonce クレームが含まれています。
nonce 発行方法
nonce の発行方法は、OID4VCI 1.0 ID1 と OID4VCI 1.0 Final (正確には OID4VCI 1.0 ID2 以降) とで大きく異なります。
OID4VCI 1.0 ID1 では、nonce はトークンエンドポイントやクレデンシャルエンドポイントから c_nonce という名前で発行されていました。下記は、OID4VCI 1.0 ID1 の 6. Successful Token Response から抜粋したトークンレスポンスのメッセージボディの例です。
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6Ikp..sHQ",
"token_type": "bearer",
"expires_in": 86400,
"c_nonce": "tZignsnFbp",
"c_nonce_expires_in": 86400,
"authorization_details": [
{
"type": "openid_credential",
"credential_configuration_id": "UniversityDegreeCredential",
"credential_identifiers": [ "CivilEngineeringDegree-2023", "ElectricalEngineeringDegree-2023" ]
}
]
}
一方、OID4VCI 1.0 Final では、『Nonce エンドポイント』という、nonce の発行に特化したエンドポイントが定義されています。
Nonce エンドポイントに HTTP POST リクエストを送ると、c_nonce プロパティを含む JSON が返ってきます。下記は OID4VCI 1.0 Final から抜粋した、Nonce エンドポイントへのリクエストとレスポンスの例です。
POST /nonce HTTP/1.1
Host: credential-issuer.example.com
Content-Length: 0
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
DPoP-Nonce: eyJ7S_zG.eyJH0-Z.HX4w-7v
{
"c_nonce": "wKI4LT17ac15ES9bw8ac4"
}
nonce の実装
nonce の発行方法の変更は、実装者泣かせの破壊的変更の一つです。
OID4VCI 1.0 仕様は、あまりに破壊的変更が多いため、旧仕様に基づく実装と最終仕様に基づく実装を共存させることができない箇所があります。そのため、Authlete ではやむなく Service.oid4vciVersion というプロパティを導入することにしました。このプロパティの値を変更することで、OID4VCI 1.0 ID1 に準拠するか OID4VCI 1.0 Final に準拠するかを切り替えられます。(OID4VCI 1.0 ID2 はサポートしません)
本記事執筆時点 (2025 年 12 月) では、Authlete の OID4VCI 1.0 Final の実装はまだ完了していません。
OID4VCI 1.0 ID1 の nonce の実装
OID4VCI 1.0 ID1 仕様を詳細に見ていくと、nonce が登場する場面では必ずアクセストークンが存在することが分かります。トークンレスポンスを返すときはアクセストークンを発行するときですし、また、クレデンシャルエンドポイントへのリクエストにはアクセストークンが含まれます。
ですので、OID4VCI 1.0 ID1 仕様の nonce は、アクセストークンと紐付けて管理することができます。実際にそのように実装した場合、アクセストークン毎に別々の nonce を作ることができ、それぞれの有効期限も別々に設定することができます。副次的な効果として、異なるクライアントが同じ nonce を共有して使うという事態は発生しえません。
OID4VCI 1.0 Final の nonce の実装
一方、OID4VCI 1.0 Final (正確には ID2 以降) では、nonce をアクセストークンに紐付けて管理することはできません。なぜなら、仕様の定義により、Nonce エンドポイントにはアクセストークン無しで誰でもアクセスできるので、Nonce エンドポイントが nonce を生成する際、アクセストークンは存在しないからです。
Nonce エンドポイントの実装の文脈では、アクセストークンどころか、どのクライアントアプリケーション (ウォレット) がアクセスしてきたかも分かりません。そのため、クライアント毎に nonce を生成することはできません。結果として、OID4VCI 1.0 Final では、(攻撃者を含む) 全てのクライアントが nonce を共有することになります。
Nonce エンドポイントは、同一タイムウィンドウ中であれば、誰がアクセスしても同じ nonce を返すことになります。であれば、ステートレスな実装で構いません。現在時刻とタイムウィンドウの長さを入力とするステートレスな nonce 生成ロジックは、例えば次のように書けるでしょう。
public String generate()
{
// タイムウィンドウの開始時刻を計算する
long roundedTime = (currentTime / timeWindow) * timeWindow;
// サーバ固有のデータ。クライアントに知られてはいけない。
//
// 実装者への注意書き:
//
// この値は秘密だが、ランダムに生成してはならない。JVM インスタンスや
// Kubernetes ポッドが異なっても、同じ値でなければならない。また、
// アプリケーションのローリング・アップデートを考慮するなら、この値は
// アプリケーションのバージョンが異なっても同じ値でなければならない。
//
// そうしないと、クライアントが異なるサーバ・インスタンスに接続して
// しまった場合に nonce 値が変わってしまう。例えば、クライアントが
// サーバ・インスタンス A にアクセスして nonce "a" を受け取り、
// その "a" を鍵証明に埋め込んでサーバに再接続したとき、もしも
// 異なる nonce 値 "b" を要求するサーバ・インスタンス B に行って
// しまった場合、その鍵証明は拒否されてしまう。
//
String serverSpecific = ...;
// nonce の計算のためにデータを組み合わせる。
String input = String.format("%d:%s:%s",
roundedTime, serverSpecific, serviceSpecific);
// input の値を推測されないようにハッシュを取る。
return base64url(sha256(input));
}
Authlete による Nonce エンドポイント実装のサポート
Nonce エンドポイントの実装をサポートするため、Authlete 3.0 に /api/{service-id/vci/nonce API を追加しました。
curl --oauth2-bearer ${ACCESS_TOKEN}\
https://${AUTHLETE_SERVER}/api/${ERVICE_ID}/vci/nonce?pretty=true
{
"action": "OK",
"responseContent": "{\n \"c_nonce\": \"a4DlXvy90rviE-IA5-8xgz6CNoF9rbpFYP2Yi_AZFxg\"\n}",
"cnonce": "a4DlXvy90rviE-IA5-8xgz6CNoF9rbpFYP2Yi_AZFxg",
"resultCode": "A504001",
"resultMessage": "[A504001] A nonce response was prepared successfully."
}
おわりに
OID4VCI 1.0 Nonce エンドポイントの仕様は「(攻撃者を含む) 全てのクライアントが nonce を共有する」という事態を招く、というのが実装者目線の結論なのですが、この理解でよいのでしょうかね?
それはさておきまして、Authlete Web サイト上の OID4VCI 解説文書は OID4VCI 1.0 Final の実装が完了したあとに更新します。しばらくお待ちください。
