Skip to content

Commit 95d39c7

Browse files
critesjoshclaude
andcommitted
Update ZK email verification with recipient binding, intent binding, nullifier, and freshness checks
Rework the circuit and contract from a simple domain-verification demo into a full email-based authorization flow: recipient binding via to-address hash, intent binding via subject hash, single-use nullifiers from the DKIM signature, and timestamp freshness checks. Update README to document the security model, contract API (including view functions), and fix Poseidon2 references. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ad2fab2 commit 95d39c7

9 files changed

Lines changed: 576 additions & 237 deletions

File tree

zkemail_verification/README.md

Lines changed: 86 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,73 @@
1-
# Verify ZK Email Proofs in Aztec Contracts
1+
# ZK Email Auth for Aztec
22

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.
3+
Proves email-based authorization using DKIM signature verification in a Noir circuit, then verifies that proof on-chain inside an Aztec private smart contract. Each email proof binds to a specific recipient, encodes an intent via the subject field, is single-use (nullifier), and must be fresh (timestamp check).
44

5-
Built to validate the [zkemail.nr](https://github.com/zkemail/zkemail.nr) library's compatibility with Aztec 4.2.0.
5+
Built with the [zkemail.nr](https://github.com/zkemail/zkemail.nr) library on Aztec 4.2.0.
66

7-
## Overview
7+
## Security Model
88

9-
This project implements:
9+
The contract enforces four constraints on each email proof:
1010

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.
11+
1. **Recipient binding** — The email's `to` address must hash to the `authorized_email_hash` stored at deployment. Only emails sent to the account owner's address are accepted.
12+
2. **Intent binding** — The email's `subject` field is hashed and compared against a caller-provided `expected_intent_hash`. This ties the email to a specific action (e.g., a Poseidon hash of calldata placed in the subject).
13+
3. **Single use** — The DKIM signature is hashed into a nullifier and pushed on-chain. Replaying the same email proof fails because the nullifier already exists.
14+
4. **Freshness** — The DKIM `t=` timestamp is extracted and checked in a public function against the block timestamp. Emails older than `max_email_age` seconds are rejected.
1515

16-
**Aztec Version**: `4.2.0-aztecnr-rc.2` (compatible with testnet `4.2.0-rc.1`)
16+
The sender's domain is also verified to be `icloud.com` via DKIM.
1717

18-
## Testnet Deployment
18+
## How It Works
1919

20-
Successfully deployed and verified on the Aztec testnet (`https://rpc.testnet.aztec-labs.com`) with real proofs enabled.
20+
```
21+
┌──────────────────────────┐
22+
Raw Email ──> [Noir Circuit] ──> │ 6 public outputs: │
23+
│ │ [0] pubkey_hash[0] │
24+
│ DKIM verify │ [1] pubkey_hash[1] │
25+
│ from domain │ [2] email_nullifier │──> [Aztec Contract]
26+
│ to address │ [3] to_address_hash │ │
27+
│ subject │ [4] intent_hash │ │ verify_honk_proof()
28+
│ timestamp │ [5] dkim_timestamp │ │ check recipient
29+
│ └──────────────────────────┘ │ check intent
30+
│ │ push nullifier
31+
│ Body verification omitted — │ check freshness
32+
│ all auth data is in DKIM-signed headers, │
33+
│ avoiding extra SHA256 hashing cost │
34+
```
2135

22-
| Step | Transaction Hash |
23-
|------|-----------------|
24-
| Account deployment | `0x11507f85ba674c2f47bb5bd71a6a039a43006bc44f84e835a0179b820d644eef` |
25-
| Contract deployment | `0x08cc244ad4363930ec56e273881c29748aeeb8cce6a710457a43811f379c9fd6` |
26-
| Email proof verification | `0x175b215e2c3bf3450c13f4494e9a099a2b2bad7eea0c9e55e659e38c65ae530b` |
36+
### Circuit (`circuit/src/main.nr`)
2737

28-
**Contract address**: `0x0f96d7850491bfe2983fb9cb7b4a77b843ad18fbfe763e776a79e2ec11cacf9f`
38+
Uses [zkemail.nr](https://github.com/zkemail/zkemail.nr) to:
39+
- Verify the 2048-bit RSA DKIM signature over the email header
40+
- Extract and verify the sender domain is `icloud.com`
41+
- Extract the `to` address and output its Poseidon2 hash (identity binding)
42+
- Extract the `subject` field and output its Poseidon2 hash (intent binding)
43+
- Parse the `t=` tag from the DKIM-Signature header (timestamp for freshness)
44+
- Output a Poseidon2 hash of the DKIM signature as an email nullifier (replay prevention)
2945

30-
## How It Works
46+
Body verification is omitted since all authorization-relevant data lives in the DKIM-signed headers. This avoids the ~114K constraint SHA256 body hash computation.
3147

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-
```
48+
### Contract (`contract/src/main.nr`)
49+
50+
- `constructor(vk_hash, authorized_email_hash, max_email_age)` — Stores the verification key hash, the Poseidon2 hash of the authorized recipient email, and the maximum email age in seconds.
51+
- `verify_email(expected_intent_hash, vk, proof, public_inputs)`**Private function** that:
52+
1. Verifies the UltraHonk proof against the stored VK hash
53+
2. Asserts `public_inputs[3]` (to address hash) matches `authorized_email_hash`
54+
3. Asserts `public_inputs[4]` (subject hash) matches `expected_intent_hash`
55+
4. Pushes `public_inputs[2]` (email nullifier) to prevent replay
56+
5. Enqueues a public call to check `public_inputs[5]` (timestamp) is fresh
57+
- `_check_email_freshness(email_timestamp, max_age)`**Public function** that checks `block.timestamp - email_timestamp <= max_age`
58+
- `get_authorized_email_hash()`**View function** that returns the stored authorized email hash
59+
- `get_max_email_age()`**View function** that returns the stored maximum email age
4460

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)
61+
### Intent Encoding
5162

52-
2. An **UltraHonk proof** is generated off-chain using Barretenberg (`@aztec/bb.js`), then verified off-chain to confirm validity.
63+
The email subject serves as the intent field. For this example, the subject is hashed with Poseidon2 inside the circuit and output as `intent_hash`. To authorize an action:
5364

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.
65+
1. Compute `intent_hash = Poseidon2(pack_31(subject_bytes) ++ [subject_length])` off-chain
66+
2. Include the intent text as the email subject (e.g., a Poseidon hash of the calldata encoded as a string)
67+
3. The circuit hashes the subject and outputs it
68+
4. The contract verifies the proof's `intent_hash` matches the caller's `expected_intent_hash`
5569

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.
70+
For a production system, the subject would contain a Poseidon hash of the calldata — easier to parse in-circuit than ASCII text and supports arbitrary call data encoding.
5771

5872
## Prerequisites
5973

@@ -74,10 +88,10 @@ nargo --version # 1.0.0-beta.18
7488
```
7589
.
7690
├── 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
91+
│ ├── src/main.nr # DKIM verify + address/subject/timestamp extraction
92+
│ └── Nargo.toml # Depends on zkemail.nr
7993
├── contract/ # Aztec smart contract
80-
│ ├── src/main.nr # verify_honk_proof + counter storage
94+
│ ├── src/main.nr # verify_honk_proof + nullifier + timestamp check
8195
│ ├── artifacts/ # Generated TypeScript bindings
8296
│ └── Nargo.toml # Depends on aztec-nr and bb_proof_verification
8397
├── scripts/
@@ -109,12 +123,6 @@ yarn ccc
109123
yarn data
110124
```
111125

112-
### Deploy to Testnet
113-
114-
```bash
115-
yarn testnet
116-
```
117-
118126
### Deploy to Local Network
119127

120128
```bash
@@ -128,41 +136,42 @@ yarn verify
128136
yarn test
129137
```
130138

139+
### Deploy to Testnet
140+
141+
```bash
142+
yarn testnet
143+
```
144+
131145
## Scripts
132146

133147
| Command | Description |
134148
|---------|-------------|
135149
| `yarn ccc` | Compile Aztec contract + generate TypeScript bindings |
136150
| `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 |
138151
| `yarn verify` | Deploy contract and verify proof on local network |
152+
| `yarn testnet` | Deploy contract and verify proof on the Aztec testnet |
139153
| `yarn test` | Run Vitest integration tests against local network |
140154

141155
## Circuit Details
142156

143-
The inner circuit verifies a DKIM-signed email from `icloud.com` using ~222K constraints:
157+
The inner circuit verifies a DKIM-signed email from `icloud.com` and extracts auth data from the headers:
144158

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 |
159+
| Component | Description |
160+
|-----------|-------------|
161+
| DKIM signature verification | RSA-2048 PKCS#1 v1.5 over SHA256 header hash |
162+
| From address extraction | Extract sender email and verify domain is `icloud.com` |
163+
| To address hashing | Extract recipient email, Poseidon2 hash for identity binding |
164+
| Subject hashing | Extract subject field, Poseidon2 hash for intent binding |
165+
| Timestamp extraction | Parse `t=` tag from DKIM-Signature for freshness check |
166+
| Nullifier computation | Poseidon2 hash of DKIM signature for replay prevention |
152167

153-
**Public outputs** (3 fields):
168+
**Public outputs** (6 fields):
154169
- `pubkey_hash[0]` — Poseidon hash of RSA modulus (root of trust)
155170
- `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.
171+
- `email_nullifier` — Poseidon2 hash of DKIM signature (prevents replay)
172+
- `to_address_hash` — Poseidon2 hash of recipient email (identity binding)
173+
- `intent_hash` — Poseidon2 hash of subject content (intent binding)
174+
- `dkim_timestamp` — DKIM signing timestamp in seconds (freshness)
166175

167176
## Troubleshooting
168177

@@ -172,15 +181,19 @@ Run `yarn ccc` to compile the contract and generate TypeScript bindings.
172181
**"Cannot find module '../data.json'"**
173182
Run `yarn data` to generate the proof data.
174183

175-
**"Failed to connect" on testnet**
176-
Check testnet status: `curl https://rpc.testnet.aztec-labs.com/status`
184+
**"Email recipient does not match authorized address"**
185+
The `to` address in the email doesn't match the `authorized_email_hash` stored at deployment. Ensure the proof was generated with the correct test email.
186+
187+
**"Email has expired"**
188+
The DKIM timestamp is older than `max_email_age`. For testing with the hardcoded test email (April 2024), use a large `max_email_age` value.
177189

178-
**Proof verification fails off-chain**
179-
Ensure the circuit was compiled with `nargo compile` after any changes, then re-run `yarn data`.
190+
**"Duplicate nullifier"**
191+
The same email proof has already been used. Each email can only authorize one action.
180192

181193
## Dependencies
182194

183195
- [zkemail.nr](https://github.com/critesjosh/zkemail.nr/tree/update/aztec-4.2.0-compat) — Noir library for DKIM email verification (Aztec 4.2.0 branch)
196+
- [poseidon](https://github.com/noir-lang/poseidon) v0.2.0 — Poseidon2 hash function for Noir
184197
- [aztec-nr](https://github.com/AztecProtocol/aztec-nr/) v4.2.0-aztecnr-rc.2 — Aztec smart contract framework
185198
- [bb_proof_verification](https://github.com/AztecProtocol/aztec-packages/) — Barretenberg proof verification for Aztec contracts
186199
- [@aztec/bb.js](https://www.npmjs.com/package/@aztec/bb.js) 4.2.0-aztecnr-rc.2 — UltraHonk proving backend

zkemail_verification/circuit/Nargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@ compiler_version = ">=1.0.0"
66

77
[dependencies]
88
zkemail = { tag = "update/aztec-4.2.0-compat", git = "https://github.com/critesjosh/zkemail.nr", directory = "lib" }
9-
sha256 = { git = "https://github.com/noir-lang/sha256", tag = "v0.3.0" }
9+
poseidon = { tag = "v0.2.0", git = "https://github.com/noir-lang/poseidon" }
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
dkim_timestamp_index = ""
2+
signature = ["", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""]
3+
4+
[dkim_header_sequence]
5+
index = ""
6+
length = ""
7+
8+
[from_address_sequence]
9+
index = ""
10+
length = ""
11+
12+
[from_header_sequence]
13+
index = ""
14+
length = ""
15+
16+
[header]
17+
len = ""
18+
storage = ["", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""]
19+
20+
[pubkey]
21+
modulus = ["", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""]
22+
redc = ["", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""]
23+
24+
[subject_header_sequence]
25+
index = ""
26+
length = ""
27+
28+
[to_address_sequence]
29+
index = ""
30+
length = ""
31+
32+
[to_header_sequence]
33+
index = ""
34+
length = ""

0 commit comments

Comments
 (0)