The President of the European Commission announced that EU age verification is now available.
On April 15th, EU Commission President Ursula von der Leyen announced that the EU Age Verification App was now available.
However, shortly after that, reports began to appear claiming that it could be "hacked in two minutes."
Attack with collaborators
The first is an attack carried out by the person themselves with the help of an accomplice. The key point is,
- Once you receive age verification credentials, you can use them an unlimited number of times.
- No PIN or biometric information is required for use.
- (There's also information suggesting these credentials aren't bound to the hardware or the App Instance, and can be transferred to other smartphones... but in the case of an attack with an accomplice, the accomplice can simply receive the credentials on the attacker's smartphone, so this itself isn't very important.)
Therefore, an attacker under 18 who obtains an accomplice who is 18 or older can receive credentials that they are 18 or older and use them without restriction. This would require things like rooting the phone, but since the person themselves would do that, it would be possible. So, from a threat modeling perspective, the question is what to do assuming that neither the person, the phone, nor the wallet app instance can be trusted, but it seems that the version released this time overlooks that point due to the principle of proportionality and so on.
Verification implementation errors by the verifier
Another report that came in suggested that age verification could be bypassed by the verifier. But I'm not sure about that... The "issuer" being used is a sample issuer, and the "verifier" is also a sample. The process is as follows:
- As a sample issuer, I will receive an mdoc/sd-jwt for age verification.
- Using this, one could log in to sample websites that require age verification.
Here it is. Please see the demo below.
However, it seems that both the issuer and the verifier are simply demonstrating how it will work when successful. There doesn't seem to be any need to verify identity to receive an mdoc/sd-jwt. And judging from the publicly available code, the verifier doesn't seem to be properly verifying anything either. Specifically,DocumentValidator.kt So, they do perform signature verification, and it seems they also verify whether the issuer is on the trust list, but even if that fails, they fill in the information that was in the credentials. trust_info It returns a data structure such as this, and within age_over_18 It appears that if such a claim is included, it will be considered as successful age verification.
However, this is only on a demo app. Of course, it would be a problem if you reused the code from this demo app to create a production site, but it seems like some people are overreacting.
However, I would like to ask those who are considering implementing this to please be careful.
- Make sure to properly verify the signature.
- We also verify the trust chain all the way back to a reliable issuer.
- This result will be reflected in access management.
Please don't forget this. This is the Digital Agency's"Expert Meeting on Clarifying Issues in Attribute Verification"But that's something I've been saying all along.
Furthermore, if we want to implement Chairman von der Leyen's principle that "it is the parents, not the platform, who protect children," we need proof of parent-child relationships, but age verification alone is not sufficient.
One more point. The age verification app discussed here is different from the "age protection framework" as defined in ISO/IEC 27566, etc. It is the "age verification" component of the "age protection framework."
Chappy analyzed the source code as of April 16th, and I've included his analysis below as an appendix. I haven't verified whether the content is correct. (I only looked at the beginning a little.) If any engineers find anything strange, I would appreciate it if you could let me know.
Appendix A. Verification of the source code for the OpenID4VP processing section using ChatGPT
- 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_tokenThe item has been 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. - The
mso_mdoc, the backend takes the MSO mdoc validator path and stores trust info.
InValidateSdJwtVcOrMsoMdocVerifiablePresentation.kt:92-101, TheFormat.MsoMdocBranch Callvalidator.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-146The 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 frequency code isDeviceResponseValidator.kt:95-125The comment at95-98says the method “does not fail due to trust issues.”104-118, ifdocumentValidator.ensureValidWithTrustInfo(document)ReturnsLeft, the code createsdefaultTrustwithissuerInTrustedList=false,issuerNotExpired=falseandsignatureValid=false, then still cannotDocumentWithTrust(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-179It 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 toSubmittedpresentation at318-334anddoInvoke(...)store 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.GetWalletResponse.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_tokenPlustrust_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.attributesThen 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.trust-info.tsx:78-145, that component shows a scorecard; it does not gate the success message.