ZK-Access turns zk proofs into modular, persistent credentials that users can reuse across contexts β without re-proving everything from scratch.
ZK-Access helps users extract verified data from zk proofs (like those from zkEmail or zkPassport) and turn it into modular credentials they can selectively disclose using new zk proofs.
For example, instead of generating three separate proofs for three job offers, a user can generate one zk proof that shows:
"Iβve received at least 3 offers that exceed yours β without revealing who made them or how much."
- Extract data from a valid zk proof (e.g., a job offer received by email).
- Structure it into a signed, modular credential.
- Generate a fresh zk proof from that credential to prove what matters, and nothing more.
All modular credentials:
- Live off-chain or on privacy-preserving chains like Aztec.
- Are hashed with Poseidon and signed using BabyJubJub-based EdDSA.
- Can be composed into a larger zk proof, combining claims like identity, age, and offers.
The Noir circuit receives up to 10 key-value pairs, each of which is:
- Hashed using Poseidon
- Signed using EdDSA over BabyJubJub
- You must use one of the three operators, optionally comparing to a target value using operations like ==, >, or <, represented by 0, 1, or 2.
It supports a modular credential structure where only non-zero slots are processed.
use std::hash::poseidon;
use eddsa::eddsa_verify;
fn main(
values: [Field; 10],
keys: pub [Field; 10],
hashes: pub [Field; 10],
compared_values: pub [Field; 10],
operations: pub [u8; 10],
signature_R8xs: pub [Field; 10],
signature_R8ys: pub [Field; 10],
signature_Ss: pub [Field; 10],
signer_x: pub Field,
signer_y: pub Field,
) {
for i in 0..10 {
let stop_flag =
(values[i] == 0) as u8 +
(keys[i] == 0) as u8 +
(hashes[i] == 0) as u8 +
(compared_values[i] == 0) as u8 +
(operations[i] == 0) as u8;
let should_process = stop_flag != 5;
if should_process {
let hash_value = poseidon::bn254::hash_1([values[i]]);
let hash_key = poseidon::bn254::hash_1([keys[i]]);
let combined_hash = poseidon::bn254::hash_2([hash_key, hash_value]);
assert(combined_hash == hashes[i]);
if operations[i] == 0 {
assert(values[i] == compared_values[i]);
} else if operations[i] == 1 {
let v = values[i] as u64;
let c = compared_values[i] as u64;
assert(v > c);
} else if operations[i] == 2 {
let v = values[i] as u64;
let c = compared_values[i] as u64;
assert(v < c);
} else {
assert(false);
}
let is_valid = eddsa_verify::<poseidon::PoseidonHasher>(
signer_x,
signer_y,
signature_Ss[i],
signature_R8xs[i],
signature_R8ys[i],
combined_hash
);
assert(is_valid);
}
}
}Each credential consists of:
- A delegation (issuer DID + signature over the entire credential)
- A
credentialSubjectcontaining key-value pairs that remain private to the holder only.
Within circuitInputs.credentialSubject, the following applies:
- Each key-value pair from the above
credentialSubjectprivate data is individually Poseidon-hashed. - Each hash is rehashed using Poseidon.
- The final hash is signed individually with BabyJubJub.
- Strings (e.g.,
"John","USD") are converted into numerical values before hashing.
This structure supports selective inclusion of any field in the zk proof.
{
"credentialSubject": {
"firstname": "John"
},
"CircuitInputs": {
"delegation": {
"signer_x": "2046135483278802193382250450395523492100932844943110199678570650020127520911",
"signer_y": "2620979215085524108106072114793342230673629628214020459503591305281855994281"
},
"firstname": {
"hash": "<poseidon hash of this two combined><poseidon hash of 'firstname'/><poseidon hash of 'John'/>",
"signature": {
"R8x": "Signature applied to the hash",
"R8y": "...",
"S": "..."
}
}
}
}In this example, we are proving that there are three acquisition offers greater than 500,000 USD. Here's how it works:
- The
valuesarray contains the three acquisition offers:800,000,1,000,000, and1,500,000 USD. - The
compared_valuesarray contains three500,000 USDvalues, which are the threshold to compare against. - The
operationsarray uses1for each comparison, meaning each acquisition offer is being checked to see if it is greater than the500,000 USDthreshold.
{
"values": ["800000", "1000000", "1500000", 0, 0, 0, 0, 0, 0, 0],
"keys": [
"148075998654880541215842830160344016242",
"148075998654880541215914887754381944178",
"148075998654880541215986945348419872114",
0, 0, 0, 0, 0, 0, 0
],
"compared_values": ["500000", "500000", "500000", 0, 0, 0, 0, 0, 0, 0],
"operations": [1, 1, 1, 0, 0, 0, 0, 0, 0, 0],
"hashes": [
"5366719375994759636904404180998763982021366406042507790915552675320521771068",
"13323871709439665891260007857896986571152387134052948088283878279824861188533",
"19638298229069844842017262203544560046611538999677861253136393249941873528030",
"0", "0", "0", "0", "0", "0", "0"
],
"signature_R8xs": [
"13002153812156373249990981593180638937036667878367606085389374543066593006225",
"608531663220951016662509278394775487793145873143918845127975337521345194342",
"13169154699769769542748201559129809346377899257394554837158454728509935306506",
"0", "0", "0", "0", "0", "0", "0"
],
"signature_R8ys": [
"17500566416431864035336449863952600816081743831636534779242734290218517156310",
"6230448208871481112583678897378695896887438341581291680354345749440472725894",
"3159692128329559535996229289614484936203147428392870913621853400499809396139",
"0", "0", "0", "0", "0", "0", "0"
],
"signature_Ss": [
"1658879383340335091247016835335256507840195899709034880603746036782115526451",
"2224586998918528195929303049173912225062515282965244201147117274934700961523",
"1364101929582381831049477345730396709590294104408983028145789249821049998512",
"0", "0", "0", "0", "0", "0", "0"
],
"signer_x": "2046135483278802193382250450395523492100932844943110199678570650020127520911",
"signer_y": "2620979215085524108106072114793342230673629628214020459503591305281855994281"
}You're selling your company. A buyer makes you an offer. You want to prove:
"I've received 3 better offers β one 20% higher, one 30%, and one 40% β without revealing who made them or how much."
With ZK-Access:
- Each offer is parsed from a zkEmail.
- Theyβre structured into modular credentials.
- One fresh zk proof proves the full story.
β Just one proof. No unnecessary disclosure.
By separating proofs from data, ZK-Access introduces a new abstraction layer: persistent, signed claims that can be reused, extended, and verified in private.
Use them across multiple proofs β without ever exposing the raw data.
- Composable Identity: Break identity into reusable claims.
- Selective Disclosure: Prove facts like "offer > $100k" without revealing the sender or content.
- Verifiable & Portable: All claims are signed and can be proven independently.
- Flexible Storage: Use it from your device or on Aztec-compatible chains.
- Frontend: Next.js + Tailwind CSS
- Backend: NestJS, Veramo, zkEmail SDK
- ZK Circuit: Noir (
main.nr) for proof verification - Storage: Off-chain or on Aztec L2
- Signing: Poseidon hash + BabyJubJub signature
β οΈ For demo purposes, no Google OAuth setup is required. The app mocks email proofs using.emlfiles.
- Clone the repo
git clone https://github.com/LuchoLeonel/zk-access
cd zk-access- Start the backend
cd backend
cp .env.example .env
yarn install
yarn dev- Start the frontend
cd frontend
cp .env.example .env
yarn install
yarn devVisit http://localhost:3000 β and click βStart Demoβ.
- Play with the credentials
If you'd like to manually play with the circuit inputs from the credential after completing the flow, you can modify or add data in the Prover.toml file. After making changes, execute the following command to run the circuit:
cd circuit
nargo executeRemember that keys, values, and compared_values strings are converted to BigInt with this function before being passed to the circuit:
const keyBigInt = BigInt('0x' + Buffer.from(key).toString('hex'));Additionally, for nested fields, the keys are combined before being converted to BigInt. For example:
const key = `offerAc[${index}].offer`;
Weβve deployed a Verifier.sol contract on Base Sepolia. You can manually interact with it using Remix, for example by calling the verify(bytes proof, bytes32[] publicInputs) function.
- Contract address:
0x20c0aa08d78cbcf3634b38ad587a9cc75b250ffc - ABI location in the repo:
frontend/src/app/proof-composer/verifier.abi.json
To verify:
- Go to Remix
- In the "Deploy & Run Transactions" plugin:
- Set environment to Injected Provider - MetaMask
- Paste the contract address in the "At Address" field
- Paste the ABI from
verifier.abi.json
- Call the
verifyfunction with your EVM-formatted proof and the array of public inputs. - Make sure you're connected to the Base Sepolia network.
You need to generate the proof and format it properly before using it in Remix.
First, execute your circuit and generate the proof:
nargo execute
bb prove -b ./target/<circuit-name>.json -w ./target/<witness-name> -o ./target --oracle_hash keccakThen, extract the EVM-compatible proof and public inputs using:
PROOF_HEX=$(cat ./target/proof | od -An -v -t x1 | tr -d $' \n' | sed 's/^.\{8\}//')
NUM_PUBLIC_INPUTS=72
HEX_PUBLIC_INPUTS=${PROOF_HEX:0:$((32 * $NUM_PUBLIC_INPUTS * 2))}
SPLIT_HEX_PUBLIC_INPUTS=$(sed -e 's/.\{64\}/"0x&",/g' <<<"$HEX_PUBLIC_INPUTS")
PROOF_WITHOUT_PUBLIC_INPUTS="${PROOF_HEX:$((NUM_PUBLIC_INPUTS * 32 * 2))}"
echo 0x$PROOF_WITHOUT_PUBLIC_INPUTS
echo "[$SPLIT_HEX_PUBLIC_INPUTS]"The first line (0x...) is the proof input for Remix.
The second block (["0x...", "0x...", ...]) is the publicInputs array.
Now you're ready to call the verify function on-chain!
/frontendβ Landing page + credential demo flow (zk-email,zk-passport)/frontend/public/emlβ Mocked emails for demo/backendβ Email parsing, credential generation, proof generation API/backend/circuitβ Noir proof logic (.nr file)
Built with β€οΈ for the Noir Hackathon.
Special thanks to zkEmail, zkPassport, Noir and the Aztec community for tooling support.
