Inspiration

More and more apps want your face now. Banks, airport gates, exam software, 2FA prompts. Most of them send that selfie to some third party company to check and store.

That bugs me. If your password leaks you just change it. If your face leaks, too bad, it's your face forever. And these companies keep piling up photos they have to guard for life.

Then I realised that the app doesn't actually need my face. It just needs to know I'm a real person, and that I haven't signed up already. You can prove both of those without sending a photo anywhere.

So I built that.

What it does

You scan your face in your own browser. It checks you're a real live person, turns your face into a hash, and mints a zero-knowledge proof on Midnight.

The video never leaves your device. Only a 32-byte hash and a liveness score go on chain.

How I built it

Three npm workspaces:

  • contract/ — the Compact circuit, mint and verifyHuman
  • api/ — thin wrapper over midnight-js for deploy, join, mint
  • ft-ui/ — React + Vite, @vladmandic/face-api for the face stuff

The scan runs every frame. TinyFaceDetector finds your face, a 68-point model maps it, and I smooth the points with an EMA at $\alpha = 0.35$ so the mesh doesn't jitter everywhere.

Then a little state machine: hold still 10 frames, turn left, turn right.

The turning is the important bit. A photo can't turn its head. I just watch where the nose sits between the jaw edges:

$$ \text{noseRel} = \frac{x_{30} - x_{0}}{|x_{16} - x_{0}|} $$

You have to push it under $0.40$ one way and over $0.60$ the other before it gives you a hash.

The math

Pixel distances alone are useless because they change when you lean in. So I divide everything by the gap between your pupils:

$$ p_{\text{left}} = \tfrac{1}{2}(L_{36} + L_{39}), \qquad p_{\text{right}} = \tfrac{1}{2}(L_{42} + L_{45}), \qquad d = \lVert p_{\text{right}} - p_{\text{left}} \rVert $$

$$ r_i = \frac{\lVert a_i - b_i \rVert}{d}, \qquad i = 1 \dots 8 $$

Now the same face gives the same numbers whether you're close or far.

Two scans never match exactly though, floats are floats. So I round each one to 2 decimals, join them, and hash:

$$ q_i = \frac{\lfloor 100 \, r_i + 0.5 \rfloor}{100}, \qquad h = \text{SHA-256}(q_1 \Vert \text{","} \Vert \cdots \Vert q_8) $$

The hash doesn't stop duplicates. The chain does:

$$ \text{assert } s \geq 70, \qquad \text{assert } h \notin \mathcal{F} $$

One face, one token, and nobody's storing a folder of photos.

Challenges I ran into

Minting to nobody. The wallet gives you keys as bech32m (mn_shield-cpk_...). I read it as hex. parseInt("mn", 16) is NaN, which becomes 0, so I sent 32 zero bytes as the owner.

The transaction went through fine. No error. Tokens just belonged to nobody. I only caught it because I looked at the owner column and saw 0x0000....

I made my own infinite loop. I added a retry to connect() thinking the extension was being flaky. But every call pops a new approval prompt, so it kept asking before I could click the first one. Unlock, asked again, unlock, asked again. Took me way too long to realise I'd built the bug I was trying to fix.

DUST. On preview 1AM pays the fees and you need nothing. On preprod you do it all yourself: faucet, wait for the wallet to sync, register your NIGHT, wait again.

I tried registering before the sync finished and it said "not initialized". So I went off hunting an init problem that didn't exist. It just wasn't done syncing.

What I learned

The rounding is both what makes this work and what makes it fragile. If two ratios land either side of a rounding line, like $1.6449$ and $1.6451$, you get totally different hashes.

So it's reliable in decent lighting, not actually robust. I'd rather just say that than pretend. Proper fix is a locality-sensitive hash or a fuzzy extractor so close values land on the same output. That's the first thing I'd redo.

Other thing: the best privacy feature here isn't the ZK proof. It's that there's no server. Nothing to hack, nothing to leak, nothing to subpoena.

What's next

The token isn't really the point, the pattern is. Any app that needs to know you're human could just check this instead of collecting its own selfies.

Voting and airdrops where one face means one account. Login with a proof instead of a stored template. Age checks that answer yes or no instead of taking your ID.

And none of it needs a new company to trust, because the check already happened on your own phone.

Built With

Share this project:

Updates

Submission history