欧州委員会委員長がEU年齢確認利用可能になったと発表
去る4月15日、EUのフォンデアライエン欧州委員会委員長がEU年齢確認アプリが利用可能になったとアナウンスしました。
ところが、それから程なくして、以下のような「2分でハックできる」というようなレポートが上がり始めました。
協力者あり攻撃
1つ目は協力者ありの本人による攻撃です。ポイントは、
- 一度年齢確認用のクレデンシャルの発行を受けると、回数無制限で使えてしまう。
- 使用にあたっては、PINや生体情報は特に必要ない。
- (このクレデンシャルは、ハードウェアにもApp Instance にもバインドされておらず、他のスマホに持っていくこともできるという情報も…。まぁ、協力者あり攻撃の場合、攻撃者のスマホで協力者がクレデンシャルの発行を受ければよいので、これ自体はあまり重要ではないですが。)
です。なので、18歳以上の協力者を得た18歳未満の攻撃者は、18歳以上というクレデンシャルの発行を受けて、それを無制限に利用可能ということになります。電話をroot化していることなどが必要になりますが、本人がやるので、それはできてしまいますね。なので、脅威モデリング的には、本人も電話もウォレットアプリのインスタンスも信用できない前提でどうするか、ということなのですが、今回リリースされたものは、比例性の原則などから、そこには目をつぶる形になっているようです。
検証者における検証実装エラー
もう一つ上がってきたレポートは、検証者において年齢認証をバイパスできるというものでした。ただこれはどうなんですかね…。使っている「発行者 (issuer)」はサンプル用の発行者ですし、「検証者 (verifier)」もサンプル用です。流れ的には、
- サンプル用発行者で、年齢確認用のmdoc/sd-jwtの発行を受ける。
- これを使うと、年齢確認を必要とするサンプルサイトにログインできてしまう。
です。以下のデモをご覧ください。
なんですが、この発行者も検証者も成功したときにはこんなふうに動くよ、というデモをやっているだけのものに見えます。mdoc/sd-jwtの発行を受けるにも特に身分証明書の確認は必要無いようですし。検証者側も公開されているコードを見る限りちゃんと検証していません。具体的には、DocumentValidator.kt では一応署名検証はしていて、発行者がトラストリストに入っているかも検証しているようですが、それが失敗してもクレデンシャルに入っていた情報を詰めた trust_info というデータ構造を返し、その中に age_over_18 という claim が入っていれば、年齢確認成功としてしまうというふうになっているように見えます。
ただ、これはあくまでデモアプリの上でですからね。もちろんこのデモアプリのコードをそのまま流用して本番サイトを作ったらアウトですが、ちょっと騒ぎすぎな感じもします。
ただまぁ、くれぐれも実装される向きにおかれましては、
- ちゃんと署名検証する
- ちゃんと信頼できる発行者までのトラストチェーンの検証もする
- この結果をアクセス管理に反映する
ことをお忘れなきように。これって、デジタル庁の「属性証明の課題整理に関する有識者会議」でも言い続けていたことなんですけどね。
あと、フォンデアライエン委員長の「子どもを守るのはプラットフォームではなく親だ」ということを実装しようとすると、親子関係の証明が必要なんですが、年齢確認だけではそれはできません。
更にもう一つ。ここで取り上げられている年齢確認アプリは、ISO/IEC 27566などでいう「年齢保障フレームワーク」とは異なります。「年齢保障フレームワーク」のうちの「年齢確認」コンポーネントの部分にあたります。
なお、チャッピーに4月16日時点のソースコードの解析をしてもらったので、以下に付録でつけておきます。内容が正しいかどうかは未検証です。(最初の方ちょっとだけ見たけど。)エンジニア各位におかれては、おかしなところなど見つかったらご教示いただければ幸いです。
付録A. ChatGPTによるOpenID4VP処理部分のソースコードの検証
- Wallet posts the response to
/wallet/direct_post.
The repo docs identify/wallet/direct_postas the wallet-response endpoint. The backend path that processes that response isPostWalletResponseLive.invokeatPostWalletResponse.kt:223-233, which callsdoInvoke(...)at235-265. - The response is submitted and each
vp_tokenitem is validated.
InPostWalletResponse.kt:318-334,submit(...)converts the wallet payload withresponseObject.toDomain(...). InsideAuthorisationResponseTO.verifiablePresentations(...), each VP element is passed tovalidateVerifiablePresentation(...).bind()atPostWalletResponse.kt:100-155, specifically136-145. - For
mso_mdoc, the backend takes the MSO mdoc validator path and stores trust info.
InValidateSdJwtVcOrMsoMdocVerifiablePresentation.kt:92-101, theFormat.MsoMdocbranch callsvalidator.validateMsoMdocVerifiablePresentation(...)and thenaddTrustInfo(transactionId, trustInfo). The trust-info store helpers are at54-68. - The backend does perform real chain and issuer-signature checks.
InDocumentValidator.kt:80-105,ensureValidWithTrustInfo(document)runs the document validation sequence. The issuer signature check isensureValidIssuerSignature(...)at137-146. The chain-trust check isensureValidChain(...)at218-226. Trust metadata is assembled inbuildTrustInfoFromResults(...)at234-263. - But trust/signature failure is downgraded to
trust_info, not enforced as rejection.
The critical code isDeviceResponseValidator.kt:95-125. The comment at95-98says the method “does not fail due to trust issues.” At104-118, ifdocumentValidator.ensureValidWithTrustInfo(document)returnsLeft, the code createsdefaultTrustwithissuerInTrustedList=false,issuerNotExpired=false, andsignatureValid=false, then still returnsDocumentWithTrust(document, defaultTrust). At122-125, it returns a successfulDocumentValidationResult. - The presentation validator then accepts the VP anyway unless
issuerAuthis missing.
InValidateSdJwtVcOrMsoMdocVerifiablePresentation.kt:159-182,validateMsoMdocVerifiablePresentation(...)callsensureValidWithTrustInfo(...)at166-171, extractsdocumentsandtrustInfosat173-174, and then only enforces thatdocument.issuerSigned.issuerAuthis present at176-179. It does not requiresignatureValid,issuerInTrustedList, orisFullyTrustedto be true before returning success at182. - Because of that, the wallet response is stored and the transaction moves to Submitted state.
Back inPostWalletResponse.kt,submit(...)returns aSubmittedpresentation at318-334, anddoInvoke(...)stores it at249-252. So the verifier backend accepts and stores the wallet response even when trust/signature failed in the permissive mdoc path above. - When the verifier UI polls
/ui/presentations/{transactionId}, the backend attachestrust_infoto the response.
The repo docs identifyGET /ui/presentations/{transactionId}as the verifier’s wallet-response endpoint. InGetWalletResponse.kt:119-132,found(...)gets the stored trust info withValidateSdJwtVcOrMsoMdocVerifiablePresentation.getTrustInfo(...), copies it into the returned wallet response, then clears the store. - The frontend polls that endpoint and receives
vp_tokenplus optionaltrust_info.
Inpresentation.ts:68-114,GetPresentationState(transactionID)fetchesGET /ui/presentations/${transactionID}. - The frontend sets
trust_info, but independently decodesproof_of_ageand uses its attributes as the success source.
InApp.tsx:178-191, ifdata.trust_infoexists it is stored, but the code then decodesdata.vp_token.proof_of_ageand setsverifiedDatafromfirstAttestation.attributes. Then atApp.tsx:211-221,isAgeOver18is computed only from whetherverifiedDatacontainsage_over_18=true. - The success message is driven by
verifiedData, while trust is rendered separately.verification-texts.tsx:19-25shows “You have successfully proven your age” purely from theeu.europa.ec.av.1:age_over_18value. Separately,App.tsx:246-253rendersTrustInfoDisplayonly as an additional component. Intrust-info.tsx:78-145, that component shows a scorecard; it does not gate the success message.