DOSSIER zk8een Apache-2.0 built in the open github.com/hamr0/8een

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 claim under refutation Age-verification mandates are being implemented on the premise that to check a fact about a person you must collect the person. The EU’s own architecture concedes this is false — and then declines to implement the fix. 8een’s case is not that ZK verification is impossible: a verifier exists and works. It is that the unlinkable path is not reachable on the protocol the web actually uses, not default anywhere, not fail-safe when it breaks, and not adoptable as a small stateless dependency — and the party the statute names as the adversary is the one the shipped default does not defend against.
Exhibit I

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.

Exhibit II

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:

  1. It is a finite anonymity budget — roughly 30 presentations, re-identification at least every 3 months (§3.4.2–3.4.3).
  2. 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.
  3. 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.
Source, pinned
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
Exhibit III

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).

  1. 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 (DcqlRequestProcessor takes no zkSystemRepository); 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.
  2. 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.
  3. 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.
  4. 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.
Sources, pinned
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
Exhibit IV

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” (ok) 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.

Enforced across the test suite — M1-EVIDENCE.md

Measured, not asserted measured:

  1. A real age_over_18 proof verifies in 0.41–0.47 s; loading the 17 pinned circuits is a 44–73 s one-time boot cost (M0, M1).
  2. 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).
  3. 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).
  4. 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.
  5. 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.

Recorded unedited, 2026-07-16, from 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.”

Exhibit V

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.

Retracted false “The EU app enables ZK proofs only in the demo build.” — ZK is configured in both shipped flavors. (What no repo can answer: whether any Member State production build exists at all. We claim neither direction.)
Retracted unfair to the EU “Batches of 30 single-use credentials = rate-limited linkability.” — Against colluding relying parties, batch issuance is genuinely unlinkable. The real gap is the issuer (Exhibit II).
Retracted false twice over “The EU’s ZK verifier is browser-only, behind a Chrome flag, off by default.” — It is a plain HTTP server, reachable from all three transports, live, and the flag is never read; the ZK path enables itself in any DC-API-capable browser.
Corrected imprecise “ZKP is optional in the spec.” — It is SHOULD (RECOMMENDED) under RFC 2119, which the spec binds itself to. “Optional” is the one word a hostile expert could falsify; the accurate sentence — recommended, yet filed under “Experimental features” — is damning enough.
Full record
EU-STACK-AUDIT.md — method, pins, and both retractions of our own unlinkability test design along the way (M2-EVIDENCE.md).
Exhibit VI

The honesty ledger — what is measured, cited, and still open

StatusClaimWhere 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.