Skip to content

Commit 718a54f

Browse files
critesjoshclaude
andcommitted
Add ZK email verification example
Noir circuit verifies DKIM-signed emails from icloud.com and an Aztec contract verifies the proof on-chain using verify_honk_proof. Includes testnet deployment scripts, integration tests, and proof generation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c563bfa commit 718a54f

15 files changed

Lines changed: 5826 additions & 0 deletions

File tree

zkemail_verification/.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
target/
2+
artifacts/
3+
node_modules/
4+
*.log
5+
.DS_Store
6+
codegenCache.json
7+
data.json
8+
store/

zkemail_verification/README.md

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
# Verify ZK Email Proofs in Aztec Contracts
2+
3+
Proves that an email was sent from a specific domain (`icloud.com`) using DKIM signature verification in a Noir circuit, then verifies that proof on-chain inside an Aztec private smart contract.
4+
5+
Built to validate the [zkemail.nr](https://github.com/zkemail/zkemail.nr) library's compatibility with Aztec 4.2.0.
6+
7+
## Overview
8+
9+
This project implements:
10+
11+
- **Noir Circuit** (`circuit/`): Verifies a 2048-bit RSA DKIM signature, checks the body hash, extracts the sender address, and asserts the sender domain is `icloud.com`. Returns three public outputs: two public key hashes (root of trust) and an email nullifier.
12+
- **Aztec Contract** (`contract/`): A private smart contract that verifies the Noir proof on-chain using `verify_honk_proof` and tracks a per-user verification count.
13+
- **Proof Generation** (`scripts/generate_data.ts`): Generates an UltraHonk proof from hardcoded test email data and verifies it off-chain before writing `data.json`.
14+
- **On-chain Verification** (`scripts/run_testnet.ts`): Deploys the contract and submits the proof for verification on the Aztec testnet.
15+
16+
**Aztec Version**: `4.2.0-aztecnr-rc.2` (compatible with testnet `4.2.0-rc.1`)
17+
18+
## Testnet Deployment
19+
20+
Successfully deployed and verified on the Aztec testnet (`https://rpc.testnet.aztec-labs.com`) with real proofs enabled.
21+
22+
| Step | Transaction Hash |
23+
|------|-----------------|
24+
| Account deployment | `0x190a55042b4a4bd063150c0b1d2a233c07e8b4b7decb3ba57e2527c646acd2be` |
25+
| Contract deployment | `0x258f5c271e67ce681cf7db8641984d78d6e7718268d6cb4b9897d8ac624709bc` |
26+
| Email proof verification | `0x2c053bad6f58bfcdea0b2bc4b918e0e31bb3d38aed740ac836e227cd5b7bca4e` |
27+
28+
**Contract address**: `0x1bbf99d2acd54c9dc9ef58fd05892fec9d5916fa66949aead6877964879a142b`
29+
30+
## How It Works
31+
32+
```
33+
Raw Email ──> [Noir Circuit] ──> UltraHonk Proof ──> [Aztec Contract] ──> On-chain Verification
34+
│ │
35+
│ DKIM verify │ verify_honk_proof()
36+
│ Body hash check │ Increment counter
37+
│ Domain = icloud.com │
38+
│ │
39+
└──> 3 public outputs: └──> VK hash stored in
40+
pubkey_hash[0] PublicImmutable storage
41+
pubkey_hash[1]
42+
email_nullifier
43+
```
44+
45+
1. The **inner circuit** (`circuit/src/main.nr`) uses the [zkemail.nr](https://github.com/zkemail/zkemail.nr) library to:
46+
- Verify the DKIM RSA signature over the email header
47+
- Extract and verify the body hash from the DKIM-Signature header against a SHA256 hash of the body
48+
- Extract the sender email address from the `From:` header
49+
- Assert the sender domain is `icloud.com`
50+
- Output the public key hash (Poseidon), redc parameter hash, and email nullifier (Pedersen)
51+
52+
2. An **UltraHonk proof** is generated off-chain using Barretenberg (`@aztec/bb.js`), then verified off-chain to confirm validity.
53+
54+
3. The **Aztec contract** (`contract/src/main.nr`) calls `verify_honk_proof(vk, proof, public_inputs, vk_hash)` inside a private function. The VK hash is stored at deployment to bind the contract to the specific circuit. On successful verification, a public counter is incremented.
55+
56+
4. With `proverEnabled: true`, the PXE generates real ClientIVC proofs that enforce the `verify_honk_proof` constraint during private kernel execution — the inner proof is cryptographically verified, not skipped.
57+
58+
## Prerequisites
59+
60+
- [Node.js](https://nodejs.org/) (v22+) and [Yarn](https://yarnpkg.com/)
61+
- [Aztec CLI](https://docs.aztec.network/getting_started/quickstart) (version 4.2.0-aztecnr-rc.2)
62+
63+
```bash
64+
# Install Aztec CLI
65+
bash -i <(curl -s https://install.aztec.network)
66+
aztec-up 4.2.0-aztecnr-rc.2
67+
68+
# Verify nargo (bundled with Aztec CLI)
69+
nargo --version # 1.0.0-beta.18
70+
```
71+
72+
## Project Structure
73+
74+
```
75+
.
76+
├── circuit/ # Inner Noir circuit (vanilla bin, not Aztec contract)
77+
│ ├── src/main.nr # DKIM verify + domain check + public outputs
78+
│ └── Nargo.toml # Depends on zkemail.nr and sha256
79+
├── contract/ # Aztec smart contract
80+
│ ├── src/main.nr # verify_honk_proof + counter storage
81+
│ ├── artifacts/ # Generated TypeScript bindings
82+
│ └── Nargo.toml # Depends on aztec-nr and bb_proof_verification
83+
├── scripts/
84+
│ ├── generate_data.ts # Build circuit inputs, generate + verify proof
85+
│ ├── run_verification.ts # Deploy + verify on local network
86+
│ ├── run_testnet.ts # Deploy + verify on Aztec testnet
87+
│ └── sponsored_fpc.ts # SponsoredFPC fee payment utility
88+
├── tests/
89+
│ └── zkemail_verification.test.ts # Vitest integration tests
90+
├── data.json # Generated proof data (created by yarn data)
91+
├── package.json
92+
├── tsconfig.json
93+
└── vitest.config.ts
94+
```
95+
96+
## Quick Start
97+
98+
```bash
99+
# Install dependencies
100+
yarn install
101+
102+
# Compile the inner Noir circuit
103+
cd circuit && nargo compile && cd ..
104+
105+
# Compile the Aztec contract and generate TypeScript bindings
106+
yarn ccc
107+
108+
# Generate proof data (generates + verifies proof, writes data.json)
109+
yarn data
110+
```
111+
112+
### Deploy to Testnet
113+
114+
```bash
115+
yarn testnet
116+
```
117+
118+
### Deploy to Local Network
119+
120+
```bash
121+
# Start local network in a separate terminal
122+
aztec start --local-network
123+
124+
# Deploy and verify
125+
yarn verify
126+
127+
# Or run the full test suite
128+
yarn test
129+
```
130+
131+
## Scripts
132+
133+
| Command | Description |
134+
|---------|-------------|
135+
| `yarn ccc` | Compile Aztec contract + generate TypeScript bindings |
136+
| `yarn data` | Generate UltraHonk proof from test email data, verify off-chain, write `data.json` |
137+
| `yarn testnet` | Deploy contract and verify proof on the Aztec testnet |
138+
| `yarn verify` | Deploy contract and verify proof on local network |
139+
| `yarn test` | Run Vitest integration tests against local network |
140+
141+
## Circuit Details
142+
143+
The inner circuit verifies a DKIM-signed email from `icloud.com` using ~222K constraints:
144+
145+
| Component | Constraints | Description |
146+
|-----------|------------|-------------|
147+
| DKIM signature verification | ~86,500 | RSA-2048 PKCS#1 v1.5 over SHA256 header hash |
148+
| Body hash (SHA256) | ~114,000 | SHA256 over email body, compared to DKIM `bh=` field |
149+
| Address extraction | ~16,000 | Extract and validate `From:` email address |
150+
| Domain check | ~100 | Assert domain bytes match `icloud.com` |
151+
| Key + nullifier hashing | ~10,200 | Poseidon hash of pubkey, Pedersen hash of signature |
152+
153+
**Public outputs** (3 fields):
154+
- `pubkey_hash[0]` — Poseidon hash of RSA modulus (root of trust)
155+
- `pubkey_hash[1]` — Poseidon hash of RSA redc parameter
156+
- `email_nullifier` — Pedersen hash of DKIM signature (prevents double-use)
157+
158+
## Contract Details
159+
160+
The `ZKEmailVerifier` contract stores a verification key hash at deployment and exposes:
161+
162+
- `verify_email(owner, vk, proof, public_inputs)`**private function** that verifies the UltraHonk proof and enqueues a public state update
163+
- `get_verification_count(owner)`**public view** that returns how many emails have been verified for an address
164+
165+
The contract uses the hybrid private/public execution pattern: proof verification happens privately (the email content is never revealed on-chain), while the verification count is updated publicly.
166+
167+
## Troubleshooting
168+
169+
**"Cannot find module '../contract/artifacts/ZKEmailVerifier'"**
170+
Run `yarn ccc` to compile the contract and generate TypeScript bindings.
171+
172+
**"Cannot find module '../data.json'"**
173+
Run `yarn data` to generate the proof data.
174+
175+
**"Failed to connect" on testnet**
176+
Check testnet status: `curl https://rpc.testnet.aztec-labs.com/status`
177+
178+
**Proof verification fails off-chain**
179+
Ensure the circuit was compiled with `nargo compile` after any changes, then re-run `yarn data`.
180+
181+
## Dependencies
182+
183+
- [zkemail.nr](https://github.com/zkemail/zkemail.nr) — Noir library for DKIM email verification
184+
- [aztec-nr](https://github.com/AztecProtocol/aztec-nr/) v4.2.0-aztecnr-rc.2 — Aztec smart contract framework
185+
- [bb_proof_verification](https://github.com/AztecProtocol/aztec-packages/) — Barretenberg proof verification for Aztec contracts
186+
- [@aztec/bb.js](https://www.npmjs.com/package/@aztec/bb.js) 4.2.0-aztecnr-rc.2 — UltraHonk proving backend
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[package]
2+
name = "icloud_email_verifier"
3+
type = "bin"
4+
authors = [""]
5+
compiler_version = ">=1.0.0"
6+
7+
[dependencies]
8+
zkemail = { path = "/workspaces/sandbox/zkemail.nr/lib" }
9+
sha256 = { git = "https://github.com/noir-lang/sha256", tag = "v0.3.0" }
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
use sha256::sha256_var;
2+
use std::{collections::bounded_vec::BoundedVec, hash::pedersen_hash};
3+
use zkemail::{
4+
dkim::RSAPubkey, headers::body_hash::get_body_hash,
5+
headers::email_address::get_email_address, KEY_LIMBS_2048, MAX_EMAIL_ADDRESS_LENGTH, Sequence,
6+
};
7+
8+
global MAX_EMAIL_HEADER_LENGTH: u32 = 512;
9+
global MAX_EMAIL_BODY_LENGTH: u32 = 1024;
10+
global EXPECTED_DOMAIN: [u8; 10] = comptime { "icloud.com".as_bytes() };
11+
12+
/// Verify a DKIM-signed email from the icloud.com domain
13+
///
14+
/// Returns:
15+
/// [0]: Poseidon hash of DKIM public key modulus
16+
/// [1]: Poseidon hash of DKIM public key redc parameter
17+
/// [2]: Pedersen hash of DKIM signature (email nullifier)
18+
fn main(
19+
header: BoundedVec<u8, MAX_EMAIL_HEADER_LENGTH>,
20+
body: BoundedVec<u8, MAX_EMAIL_BODY_LENGTH>,
21+
pubkey: RSAPubkey<KEY_LIMBS_2048>,
22+
signature: [Field; KEY_LIMBS_2048],
23+
body_hash_index: u32,
24+
dkim_header_sequence: Sequence,
25+
from_header_sequence: Sequence,
26+
from_address_sequence: Sequence,
27+
) -> pub [Field; 3] {
28+
assert(header.len() <= MAX_EMAIL_HEADER_LENGTH);
29+
assert(body.len() <= MAX_EMAIL_BODY_LENGTH);
30+
31+
// Verify the DKIM signature over the header
32+
pubkey.verify_dkim_signature(header, signature);
33+
34+
// Extract the body hash from the DKIM-Signature header and verify against computed body hash
35+
let signed_body_hash = get_body_hash(header, dkim_header_sequence, body_hash_index);
36+
let computed_body_hash: [u8; 32] = sha256_var(body.storage(), body.len() as u32);
37+
assert(
38+
signed_body_hash == computed_body_hash,
39+
"SHA256 hash computed over body does not match body hash found in DKIM-signed header",
40+
);
41+
42+
// Extract the from email address
43+
let from_field_name = comptime { "from".as_bytes() };
44+
let from_address: BoundedVec<u8, MAX_EMAIL_ADDRESS_LENGTH> =
45+
get_email_address(header, from_header_sequence, from_address_sequence, from_field_name);
46+
47+
// Find the '@' in the from address and verify the domain matches "icloud.com"
48+
let mut at_index: u32 = 0;
49+
let mut found_at = false;
50+
for i in 0..MAX_EMAIL_ADDRESS_LENGTH {
51+
if i < from_address.len() {
52+
if from_address.get_unchecked(i) == 64 {
53+
at_index = i + 1;
54+
found_at = true;
55+
}
56+
}
57+
}
58+
assert(found_at, "No @ symbol found in from address");
59+
60+
// Check the domain length matches
61+
let domain_len = from_address.len() - at_index;
62+
assert(domain_len == 10, "Domain length does not match icloud.com");
63+
64+
// Check each byte of the domain
65+
for i in 0..10 {
66+
assert(
67+
from_address.get_unchecked(at_index + i) == EXPECTED_DOMAIN[i],
68+
"Email domain does not match icloud.com",
69+
);
70+
}
71+
72+
// Compute standard outputs
73+
let email_nullifier = pedersen_hash(signature);
74+
let pubkey_hash = pubkey.hash();
75+
[pubkey_hash[0], pubkey_hash[1], email_nullifier]
76+
}
77+
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[package]
2+
name = "ZKEmailVerifier"
3+
type = "contract"
4+
authors = [""]
5+
6+
[dependencies]
7+
aztec = { git = "https://github.com/AztecProtocol/aztec-nr/", tag = "v4.2.0-aztecnr-rc.2", directory = "aztec" }
8+
bb_proof_verification = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.2.0-aztecnr-rc.2", directory = "barretenberg/noir/bb_proof_verification" }
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
use aztec::macros::aztec;
2+
3+
#[aztec]
4+
pub contract ZKEmailVerifier {
5+
use aztec::{
6+
macros::{functions::{external, initializer, only_self, view}, storage::storage},
7+
oracle::logging::debug_log_format,
8+
protocol::{address::AztecAddress, traits::ToField},
9+
state_vars::{Map, PublicImmutable, PublicMutable},
10+
};
11+
use bb_proof_verification::{UltraHonkVerificationKey, UltraHonkZKProof, verify_honk_proof};
12+
13+
#[storage]
14+
struct Storage<Context> {
15+
verification_count: Map<AztecAddress, PublicMutable<Field, Context>, Context>,
16+
vk_hash: PublicImmutable<Field, Context>,
17+
}
18+
19+
#[initializer]
20+
#[external("public")]
21+
fn constructor(owner: AztecAddress, vk_hash: Field) {
22+
self.storage.verification_count.at(owner).write(0);
23+
self.storage.vk_hash.initialize(vk_hash);
24+
}
25+
26+
#[external("private")]
27+
fn verify_email(
28+
owner: AztecAddress,
29+
verification_key: UltraHonkVerificationKey,
30+
proof: UltraHonkZKProof,
31+
public_inputs: [Field; 3],
32+
) {
33+
debug_log_format("Verifying email proof for owner {0}", [owner.to_field()]);
34+
let vk_hash = self.storage.vk_hash.read();
35+
verify_honk_proof(verification_key, proof, public_inputs, vk_hash);
36+
self.enqueue_self._increment_verification_count(owner);
37+
}
38+
39+
#[only_self]
40+
#[external("public")]
41+
fn _increment_verification_count(owner: AztecAddress) {
42+
let current = self.storage.verification_count.at(owner).read();
43+
self.storage.verification_count.at(owner).write(current + 1);
44+
}
45+
46+
#[view]
47+
#[external("public")]
48+
fn get_verification_count(owner: AztecAddress) -> Field {
49+
self.storage.verification_count.at(owner).read()
50+
}
51+
}

zkemail_verification/package.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"name": "zkemail_verification",
3+
"module": "index.ts",
4+
"type": "module",
5+
"scripts": {
6+
"clean": "rm -rf store",
7+
"ccc": "cd contract && aztec compile && aztec codegen target -o artifacts",
8+
"data": "tsx scripts/generate_data.ts",
9+
"verify": "tsx scripts/run_verification.ts",
10+
"testnet": "tsx scripts/run_testnet.ts",
11+
"test": "yarn clean && vitest run",
12+
"test:watch": "vitest"
13+
},
14+
"devDependencies": {
15+
"@types/node": "^22.0.0",
16+
"vitest": "^3.0.0"
17+
},
18+
"peerDependencies": {
19+
"typescript": "^5.0.0"
20+
},
21+
"dependencies": {
22+
"@aztec/accounts": "4.2.0-aztecnr-rc.2",
23+
"@aztec/aztec.js": "4.2.0-aztecnr-rc.2",
24+
"@aztec/bb.js": "4.2.0-aztecnr-rc.2",
25+
"@aztec/kv-store": "4.2.0-aztecnr-rc.2",
26+
"@aztec/noir-contracts.js": "4.2.0-aztecnr-rc.2",
27+
"@aztec/noir-noir_js": "4.2.0-aztecnr-rc.2",
28+
"@aztec/pxe": "4.2.0-aztecnr-rc.2",
29+
"@aztec/wallets": "4.2.0-aztecnr-rc.2",
30+
"@aztec/stdlib": "4.2.0-aztecnr-rc.2",
31+
"tsx": "^4.20.6"
32+
},
33+
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
34+
}

0 commit comments

Comments
 (0)