SecFall EN FR

Ce site existe aussi en français.

Lire en français →

← Back to all work

case study · 1st prize

Project Keter

Proving you’re allowed to trade a regulated token, without saying who you are.

The brief

Security tokens are shares and bonds that live on a blockchain. Regulation says the issuer has to know who holds them; a public blockchain shows every transfer to everyone. Put the two together and every investor’s identity ends up on display.

We had a weekend to try a way out: let each investor prove they meet the rules (an EU country, an approved investor type, a personal limit) without revealing which investor they are. The token only checks the proof.

The approach

The bank approves each investor’s KYC and stores a fingerprint of their details in a tree; only the tree’s root goes on-chain. To make a transfer, the investor’s browser builds a zero-knowledge proof that their details are in the tree and pass the rules, and the token contract checks that proof before moving anything.

The public chain sees a yes or a no, never the person. The bank is different: it approved the KYC, so it knows its investors. The privacy is from everyone else, not from the bank.

What holds

  • A proof can’t be reused: each one leaves a one-time mark that the contract records.
  • The contract checks that the proof matches the actual sender, recipient and amount before accepting it.

What doesn’t, yet

What I found re-reading the code after the event. It was built in a weekend, not audited.

  1. The check can be skipped. The token’s ordinary transfer functions move tokens with no proof at all; only the proof-carrying transfer checks one. That alone would stop this from going anywhere real.
  2. One investor’s proof says nothing about the person receiving. Checking the recipient needs a second proof, and only one of the transfer paths asks for it.
  3. The amount check can be fooled: amounts are squeezed into 64 bits before they’re compared with the limit, so a huge amount could wrap around and pass.
  4. Removing one investor means publishing a new root, which breaks every pending proof at once.
  5. The tree only fits 16 investors, and age is stored but never checked.

How a transfer flows

The investor tree: 16 investors, details redacted, one root on-chain on-chain: all anyone sees root token contract yes the proof 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
Pick an investor. Their path to the root lights up, the neighbours the proof needs get circled, and the contract only ever sees the root.

Built with

Proofs
Noir circuits, generated in the browser with NoirJS
On-chain
Solidity verifier and token contracts on Sepolia
Token standard
ERC-1400 security tokens
App
Next.js, MetaMask
Show the circuit

The heart of the circuit my teammate wrote (circuit/src/main.nr, condensed). The investor’s details stay private; only the root, the one-time mark, sender, recipient and amount are public.

  let leaf = hash_8([name, surname, age, address, wallet, country_code, investor_type, kyc_face]);
  let leaf_final = hash_2([hash_2([leaf, salt]), max_amount]);

  assert(nullifier == hash_2([salt, nonce]), "InvalidNullifier");
  assert(wallet == sender, "UnauthorizedSender");
  // … walk the Merkle path up to merkle_root …
  assert(country_valid, "InvalidCountryCode");
  assert(amount as u64 <= max as u64, "AllocationLimitExceeded");