Age verification · zero-knowledge · the missing half
Over 18 is one bit.
Here is the verifier the EU didn’t ship.
8een is a small, open, stateless component that checks a zero-knowledge age proof and answers exactly one bit — over the threshold, or not — while learning nothing else. No name, no birthdate, no document, no identifier crosses the boundary. The cryptography is google/longfellow-zk (IETF draft-google-cfrg-libzk) — the same library the EU’s own age-verification app carries in every build. 8een authors zero cryptography.
This page is the argument, written down with citations. Every claim is either measured by code in the open repository or cited to a primary source; the file, line, and commit pins live in the evidence log each claim links to. The page says which is which.
The statute demands unlinkability — and names the issuer as the adversary
eIDAS 2.0 — Regulation (EU) 2024/1183, Article 5a(16) — verbatim from the Official Journal:
16. The technical framework of the European Digital Identity Wallet shall:
(a) not allow providers of electronic attestations of attributes or any other party, after the issuance of the attestation of attributes, to obtain data that allows transactions or user behaviour to be tracked, linked or correlated, or knowledge of transactions or user behaviour to be otherwise obtained, unless explicitly authorised by the user;
(b) enable privacy preserving techniques which ensure unlikeability [sic — the typo is in the published Official Journal; the intended word is “unlinkability”], where the attestation of attributes does not require the identification of the user.
Regulation (EU) 2024/1183, Art. 5a(16) — EUR-Lex, Official Journal
The load-bearing words are in (a): it names the attestation provider — the issuer — as a party that must not be able to obtain data that links transactions. Keep that in view through Exhibit II: the shipped privacy mechanism defends against colluding websites; against the issuer, the spec’s only safeguard is a storage policy — and the one mechanism the spec itself credits with ensuring unlinkability is the zero-knowledge proof.
Scope, stated precisely: Art. 5a governs the EUDI Wallet. The age-verification app is a separate, blueprint-derived mini-wallet — it inherits the design constraint of 5a(16) rather than being directly bound by it. And (b) is the weaker limb — a duty to “enable” techniques. The argument here leans on (a), which is outcome-shaped.
The spec concedes ZK is the fix — then files it under “Experimental”
The EU age-verification technical specification (release 1.0.9) binds itself to RFC 2119, so its verbs are load-bearing. An AV app SHALL implement the plain presentation protocols (§4.2) and SHOULD — RECOMMENDED, not optional — implement the ZKP mechanism; a relying party SHOULD implement ZKP verification (§4.4). The ZKP mechanism itself sits in the chapter titled “Experimental features”: “a next version … will include as an experimental feature the Zero-Knowledge Proof (ZKP) solution” (§7.1). The mandatory path is plain mdoc — where the relying party sees the actual credential.
The shipped privacy mechanism, treated fairly
The spec’s answer to linkability is batch issuance: ~30 single-use attestations (a recommendation, §3.4.1), each bound to a distinct device key. Against colluding relying parties this is genuinely unlinkable — two websites comparing notes see two different signatures over two different keys, and there is no credential-borne correlator. An earlier version of this argument called that “linkable”; it was wrong, and the correction is recorded in Exhibit V.
What remains true, and is the real gap:
- It is a finite anonymity budget — roughly 30 presentations, re-identification at least every 3 months (§3.4.2–3.4.3).
- It does not protect against the issuer, the party Art. 5a(16)(a) names: an attestation provider that retained the batch→user mapping could re-identify every presentation. The spec’s answer is that it “does not require” the provider to store any permanent information (§Data minimisation) — a policy, not a cryptographic guarantee.
- The spec reserves “ensuring unlinkability” for ZKP alone. Annex B §B.1: a ZKP-based approach “enhances privacy by ensuring unlinkability, making it computationally infeasible for the Relying Party to associate multiple proofs with the same individual.” And §Unlinkability: “Initially, the solution will rely on batch issuance … Zero-Knowledge Proof (ZKP) mechanisms will be considered.” No spec text makes an unlinkability promise about the issuer for any mechanism.
eu-digital-identity-wallet/av-doc-technical-specification @ 5eb8a03 (release 1.0.9, 2026-03-19) · §1.4 (RFC 2119), §3.4, §4.2, §4.4, §7.1, Annex B §B.1 · full audit: EU-STACK-AUDIT.md
What actually ships: full disclosure by default, silently on failure
Four findings from an adversarial read of the EU’s own repositories — each handed to an independent check instructed to refute it, each pinned to file, line, and commit. This audit also killed four of 8een’s own prior claims (Exhibit V).
-
On the protocol a website actually uses, the wallet cannot produce a ZK proof at all.
In wallet-core v0.28.1 the OpenID4VP request processor is constructed
without the ZK machinery (
DcqlRequestProcessortakes nozkSystemRepository); only the browser Digital-Credentials-API and proximity paths receive it. ZK is carried in every build of the AV app — and is unreachable from the mainstream web protocol. -
The flagship verifier cannot check one. The EU’s OpenID4VP verifier endpoint contains
no ZK support of any kind: no ZK dependency, a closed format dispatch
(
{SdJwtVc, MsoMdoc, else → throw}), and zero ZK-related commits in its entire history. This survived a deliberate attempt to break it. -
When proof generation fails, the wallet silently discloses the entire document.
The default policy is
ZkResponsePolicy.FallbackToFullDisclosure; on any ZK failure the full mdoc is sent instead. The library’s own documentation calls the safe setting (Strict) “recommended for production use to prevent unintended full document disclosure” — and neither the AV app nor wallet-core ever sets it. A security mechanism that fails by disclosing everything, silently, is the exact failure shape this project exists to refuse. - The one server-side ZK verifier that exists is a vendored wallet SDK, not a drop-in. It is real, deployed, and it works — a vendored copy of OpenWallet’s Multipaz, to which the EU authored ~370 lines (trust anchors, a doctype, CORS); none of the cryptography. A mid-size site cannot drop a wallet SDK into a request path. That adoptable, stateless verifier is the missing half — Exhibit IV.
av-app-android-wallet-ui @ f8fde51 · eudi-lib-android-wallet-core v0.28.1 · eudi-lib-android-iso18013-data-transfer v0.14.0 · eudi-srv-web-verifier-endpoint-23220-4-kt @ de66a3e · av-dc-api-backend @ 2418006 · file/line pins for every claim: EU-STACK-AUDIT.md
The missing half, working
8een is the adoptable verifier: vanilla Node, zero runtime dependencies, stateless by design — it stores nothing, so there is nothing to breach. It drives the longfellow verifier and returns one bit behind one invariant:
“Did we get an answer” (
Enforced across the test suite — M1-EVIDENCE.mdok) is separate from “what the answer was” (over_threshold), and a verifier that cannot verify says “I cannot verify” — never “you are underage.” A broken verifier reporting “no” would turn away every legitimate adult while sounding exactly like a working one — the same failure shape as Exhibit III.3, refused by construction.
Measured, not asserted measured:
- A real
age_over_18proof verifies in 0.41–0.47 s; loading the 17 pinned circuits is a 44–73 s one-time boot cost (M0, M1). - Tampered proof bytes, a tampered session transcript, a stale certificate chain, an untrusted issuer, an underage credential — each refused, each attributed to its layer, in a negative matrix minted fresh at test time under a runtime-generated CA (M2).
- EU interop is byte-real: the EU stack’s 12 circuits are sha256-identical to 8een’s pinned set, and 8een verifies a proof generated by the EU’s own prover — a circuit-labeling divergence was found and bridged, label-resolution only (M3).
- Unlinkability, probed: two presentations of the same credential share no detectable per-credential identifier down to a measured ~11-byte detection floor — a black-box byte probe with a planted-identifier positive control, watched failing before its null result was believed (M2). Full cryptographic unlinkability rests on the scheme’s own analysis cited.
- Replay-safe by default at the adoption layer: the HTTP gate issues a single-use nonce and
spends it; a byte-identical replay is refused (
replay_detected) as a “cannot verify,” never a “no.” An expired credential is refused by default (M4).
Watch the property that matters
The recording below is the replay defence, unedited: the same real proof is accepted once and refused on replay — then a fresh session is accepted again. The refusal is the spent-nonce memory firing, not a broken proof.
demo/server.js at commit
a023174 (v0.4.0) — real
longfellow verification, real single-use nonce spend; the only stub is the wallet (pre-minted
proofs under a runtime test CA — see the ledger, Exhibit VI). Reproduce it yourself:
node demo/server.js after the documented build.
Adopt it
import { startGate } from 'zk8een';
import express from 'express';
const gate = await startGate({
binary: './longfellow-verifier',
circuitDir: './circuits',
caCerts: './issuers.pem', // THE trust boundary — choose it deliberately
challengeSecret: process.env.EIGHTEEN_SECRET, // ≥16 bytes, stable, shared across replicas
store: 'memory', // dev shortcut; Redis-backed nonceStore in production
});
const app = express();
app.use(gate.express()); // GET /8een/challenge + POST /8een/verify
app.listen(3000, '127.0.0.1'); // loopback; never 0.0.0.0 unless deliberately public
ok:true → HTTP 200, branch on over_threshold;
ok:false → HTTP 503, “could not verify — re-challenge”: never a status that reads as
“this person is denied.”
Corrections — claims of ours the evidence killed
Each claim in this dossier was handed to an independent check instructed to refute it. Four of the project’s own public claims did not survive, and are retracted here rather than quietly fixed — an argument that cannot show its corrections cannot be trusted with its claims.
EU-STACK-AUDIT.md — method, pins, and both retractions of our own unlinkability test design along the way (M2-EVIDENCE.md).
The honesty ledger — what is measured, cited, and still open
| Status | Claim | Where the evidence lives |
|---|---|---|
| measured | Verification discriminates: valid accepted (0.41–0.47 s), tampered/stale/untrusted/underage each refused at its own layer. | M0, M2 |
| measured | No per-credential identifier in proof bytes, to a ~11-byte detection floor — an 8-byte or non-contiguous identifier would evade the probe. | M2 |
| measured | EU circuit byte-identity (12/12 sha256-equal) and verification of a proof from the EU’s own prover. | M3 |
| measured | Expired credential refused by default; byte-identical replay refused with single-use on — and accepted with it off, asserted by a test that exists so nobody forgets the stateless default. | M4 |
| cited | Full cryptographic unlinkability of the scheme — rests on longfellow’s IETF draft and the independent security review it is under, not on our probe. | draft-google-cfrg-libzk |
| open | No proof from a real phone has ever reached this verifier — M3 was met via the EU’s own server-side prover (the Android emulator is a documented dead end on the build host); the on-phone circuit-id label remains unpinned. | M3 |
| open | The demo’s wallet is a stub — pre-minted real proofs under a runtime test CA; a real proof can only come from a wallet holding the credential’s own device key. Whether any Member State production build or real-user issuance exists is not answerable from the public repos — we claim neither direction. | demo/server.js, audit |
| open | Not yet on npm: the package drives a ~10.5 MB verifier binary it does not ship; publishing before binary distribution is solved would install a verifier that cannot verify. | README |
| out of scope | Issuance integrity (the enrollment step is the issuer’s; a verifier cannot fix it and pretending otherwise would be dishonest) — stated plainly, by design. | PRD §8 |
Statelessness is the security argument, not a limitation: 8een keeps no storage, no accounts, no telemetry — the only state anywhere is the adopter’s own spent-nonce set. The recurring bug this project keeps finding — in its own code and in Exhibit III — is a security component that half-loads and answers confidently anyway. Every layer here refuses to serve rather than serve wrongly.