Skip to content

Signer-domain fallback, c=simple headers (+ field-span soundness fix), readable quoted-printable with correct masking - #23

Merged
Divide-By-0 merged 9 commits into
mainfrom
stack/04-signer-qp-simple
Sep 30, 2026
Merged

Divide-By-0 merged 9 commits into
mainfrom
stack/04-signer-qp-simple

Conversation

@Divide-By-0

@Divide-By-0 Divide-By-0 commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

What was asked

Aayush ran the Noir circuits over the 132 test emails in private-emls and asked for:

  1. DKIM verification that falls back to the signature's d= domain;
  2. readable handling of quoted-printable (QP) emails ("encoding/decoding");
  3. an XL tier for longer emails, in parallel (separate branch, restacked on this one);
  4. support for c=simple header canonicalization;
  5. an evaluation of ProveKit for faster on-device proving.

The overall goal is that every proof can be generated on people's own devices. Noir only; no Circom.

Stacked on #21 (base stack/02-long-emails-noir-rc3).

What changes

1. Signer-domain fallback (site + CLI). verifyDKIMSignature(email) only looks for a signature from the From: domain. ESP-signed mail (ccsend.com, schoolmessenger.com) and subdomain-signed mail (email.airbnb.com) failed although a valid signature exists: 17 of the 132 emails, 10 of which then verify. The CLI and site now try From: first, then each DKIM-Signature d=. The CLI's verify already prints d= and warns when it isn't the From domain. Test: a synthetic ESP-signed email with stubbed DNS-over-HTTPS fails on #21's CLI and passes here.

4. c=simple headers + a field-span soundness fix (vendored zkemail.nr patches, documented in VENDORED.md):

  • field names match case-insensitively (DKIM-Signature);
  • a CR inside a field is allowed only as a fold (CRLF + SP/HTAB);
  • bh= is also accepted after ; and the folded ;\r\n\t / ;\r\n .
  • Security fix: the "no newline inside the field" check scanned only the first 300 bytes, and the field length was never checked. So a claimed DKIM field could run across a real header boundary into a later header whose bh= the prover picks. The limit is now 1,024 bytes and asserted. Test: this attack is accepted by Prove 48 KB emails + security upgrade to Noir 1.0.0-rc.3 / zkemail.nr v2 (redc binding) #21's circuit and rejected here. The same gap exists upstream in zkemail.nr.
  • The site and CLI locate the last DKIM-Signature field themselves; zkemail-nr's lookup is case-sensitive and stops at the first line break.

2. Quoted-printable, in the display layer. I first built an in-circuit decoding variant (b9791be). It cost about 55 gates per byte, and the large variant needed 2²³ gates and OOM'd compiling at 20.3 GB, which works against on-device proving. It also adds no soundness: decoding is a fixed public function of the proven raw bytes, so a verifier can repeat it. So:

  • The verify page and CLI show QP bodies decoded, with a note that the proof covers the encoded bytes.
  • Web privacy bug fixed: the UI searched for the highlighted text (Café, text across soft line breaks) inside the encoded bytes, didn't find it, and published it unmasked. Masks now map from decoded characters to the exact raw bytes (viewMaskToRawMask). A regression test shows the old mapping leaks, and a Chromium test on 3 real QP emails confirms the hidden phrase is absent from the proof and that the old mapping would not have hidden it.
  • Masks now convert per character to UTF-8 bytes; offsets used to drift after the first non-ASCII character.
  • The CLI's --hide/--reveal already matched QP-encoded copies; inspect, the preview and verify now show decoded text.

Circuits rebuilt (nargo 1.0.0-rc.3, bb 5.0.0). 2048-bit gates:

Tier Gates Domain
small 558,745 2²⁰
mid 889,679 2²⁰
large 4,066,238 96.9% of 2²²

The large body cap is now 48,000 bytes (was 49,152), to stay within 2²² after the c=simple hardening. The browser prover's CRS stays at 2²⁰: 2²¹ points is 134 MB, over Chromium's ~127 MB IndexedDB per-value limit, which I hit and fixed.

ProveKit (evaluated, not adopted)

Measured against bb 5.0.0 on this machine (details in docs/provekit-eval.md on branch stack/06-provekit-eval):

  • Speed: slower than bb. Small proves in 17.1 s vs 6.8 s, mid in 62 s vs 20.5 s.
  • Large tier: its one-time setup never finished (stopped after 2+ hours).
  • Size: proofs are about 800 KB vs 15 KB, and verifier keys 15–23 MB vs 3.7 KB.
  • Compatibility: its audited v1 branch lacks the multi-scalar-multiplication black box our Pedersen nullifier needs.
  • Browser: proving works in WASM, but no faster than bb.js.

Revisit if its witness solver gets parallelized, or if we target phones, where its file-backed memory mode is the one real advantage.

Tests

  • yarn test: 19/19 pass. New in this PR: signer fallback, c=simple proves, two field-span attacks, QP decode parity, QP mask mapping plus the old-mapping regression, and display decoding.
  • vendored zkemail.nr nargo test: 49/49.
  • Coverage over the 132 local emails (witness execution; nothing printed or published): 55 prove, up from 41 before this work.
    • 20 are too long; the XL tier (restack in progress) takes 14 of them.
    • 1 Fastmail email has a bh= value folded mid-value, which is still unsupported (follow-up).
    • The rest fail DKIM before the circuit: rotated keys, altered or forwarded emails, known-failure fixtures. DNS is flaky under load, so a few move between runs.
  • Browser: 3 real QP emails proved in Chromium in 15–19 s, verified, and the hidden text was absent from the output.

Note: this PR's base isn't main, so CI won't run until it's retargeted. All suites were run locally.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Quoted-printable email bodies are decoded for inspection, previews, and proof display. Redactions made against decoded text still apply to the corresponding encoded content covered by the proof.
    • DKIM verification now supports signatures from a domain different from the sender’s From: address, simple header canonicalization, and more folded or longer signature fields.
  • Changes
    • Reduced the maximum email body size for large proving tiers from 49,152 to 48,000 bytes.

Divide-By-0 and others added 9 commits September 29, 2026 00:04
…signature

verifyDKIMSignature(email) only looks for a signature from the From:
domain; ESP/subdomain-signed mail failed although a valid signature
exists (17/132 local test emails; 10 then verify). CLI and site now try
From:, then each DKIM-Signature d=. New test (synthetic email, stubbed
DoH) fails on the #21 CLI and passes here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pan gap

zkemail.nr (vendored) patches:
- header field names match case-insensitively (c=simple keeps
  'DKIM-Signature'),
- a CR inside a field is only allowed as a fold (CRLF + SP/HTAB),
- bh= also accepted after ';' and folded ';\r\n\t' / ';\r\n ',
- field length is now asserted <= MAX_DKIM_HEADER_FIELD_LENGTH (300 ->
  1024). Previously only the first 300 bytes were scanned for line
  breaks and length was unchecked, so a claimed DKIM field could run
  into a later header line whose bh= the prover picks (test: accepted by
  the #21 circuit, rejected now).
Site + CLI locate the (last) DKIM-Signature field themselves; zkemail-nr's
lookup is case-sensitive and stops at the first line break.

Artifacts are rebuilt in a later commit (with QP decoding).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sk mapping; drop in-circuit QP

Reverts the in-circuit QP decode variant (b9791be): ~55 gates/byte, QP large
needed 2^23 and OOM'd compiling at 20.3 GB, and it added no soundness
(decoding is a public function of the proven raw bytes).

Instead:
- site + CLI show QP bodies decoded (inspect, preview, verify page, CLI verify),
- decoded-text masks map to the exact raw bytes (viewMaskToRawMask), fixing a
  web privacy bug: highlighted QP text ('Café', text across soft breaks) was
  searched for in the encoded bytes, not found, and published unmasked
  (regression test),
- per-character masks now convert to UTF-8 byte masks (offsets drifted after
  the first non-ASCII character),
- large tier body cap 49,152 -> 48,000 to stay within 2^22 after the c=simple
  hardening.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…redc range check, large=48,000)

Gates (2048-bit): small 558,745 (2^20), mid 889,679 (2^20), large 4,066,238
(96.9% of 2^22). nargo 1.0.0-rc.3, bb 5.0.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ndexedDB value limit)

bb.js caches the uncompressed CRS as one IndexedDB value; 2^21 points =
134 MB > ~127 MB, so every browser proof threw. Browser tiers are <= 2^20.
Verified on 3 real quoted-printable emails in Chromium: proofs valid in
15-19 s, the hidden phrase is absent from the output (the old search-based
mapping would not have hidden it), and the body is shown decoded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: b816e29a-f8e3-4007-b627-6462e59eb534

📥 Commits

Reviewing files that changed from the base of the PR and between dee8911 and 32344eb.

📒 Files selected for processing (32)
  • .agents/skills/redacted-circuit-maintenance/SKILL.md
  • .gitignore
  • README.md
  • skills/redacted-email-proof/SKILL.md
  • skills/redacted-email-proof/scripts/redacted.mjs
  • src/circuit-configs.json
  • src/circuit/target/email_mask_1024_large.json
  • src/circuit/target/email_mask_1024_mid.json
  • src/circuit/target/email_mask_1024_small.json
  • src/circuit/target/email_mask_2048_large.json
  • src/circuit/target/email_mask_2048_mid.json
  • src/circuit/target/email_mask_2048_small.json
  • src/circuit/target/vk/email_mask_1024_large.vk
  • src/circuit/target/vk/email_mask_1024_mid.vk
  • src/circuit/target/vk/email_mask_1024_small.vk
  • src/circuit/target/vk/email_mask_2048_large.vk
  • src/circuit/target/vk/email_mask_2048_mid.vk
  • src/circuit/target/vk/email_mask_2048_small.vk
  • src/circuit/vendor/VENDORED.md
  • src/circuit/vendor/zkemail/src/headers/body_hash.nr
  • src/circuit/vendor/zkemail/src/headers/mod.nr
  • src/circuit/vendor/zkemail/src/lib.nr
  • src/lib.ts
  • src/pages/VerifyPage.tsx
  • src/utils/dkimFields.ts
  • src/utils/dkimSigner.ts
  • src/utils/emlParser.ts
  • src/utils/qp.ts
  • tests/dkim-signer.test.ts
  • tests/fixtures/doh-stub.mjs
  • tests/header-canonicalization.test.ts
  • tests/qp.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The changes add quoted-printable decoding for email body display and mask mapping, DKIM signer-domain fallback, and expanded DKIM header handling. They also update large-tier body limits, circuit references, browser proving settings, tests, and maintenance documentation.

Changes

Email proof and DKIM compatibility

Layer / File(s) Summary
Quoted-printable body views and masks
src/utils/qp.ts, src/lib.ts, src/pages/VerifyPage.tsx, skills/redacted-email-proof/scripts/redacted.mjs, tests/qp.test.ts
Quoted-printable bodies are decoded for inspection and proof previews. Decoded-text masks map to raw body bytes for proof generation. Proof extraction returns whether the displayed body was decoded.
DKIM signer-domain fallback
src/utils/dkimSigner.ts, src/utils/emlParser.ts, src/lib.ts, skills/redacted-email-proof/scripts/redacted.mjs, tests/dkim-signer.test.ts, tests/fixtures/doh-stub.mjs
When no domain is specified, DKIM verification tries the default verification and then distinct signer domains from the message. The parser and CLI use this fallback.
DKIM field parsing and circuit constraints
src/utils/dkimFields.ts, src/lib.ts, skills/redacted-email-proof/scripts/redacted.mjs, src/circuit/vendor/zkemail/src/headers/*, src/circuit/vendor/zkemail/src/lib.nr, src/circuit/vendor/VENDORED.md, tests/header-canonicalization.test.ts
The site and CLI locate DKIM fields and body-hash tags. The circuit handles case-insensitive field names, folded fields, additional bh= placements, and a 1024-byte field limit. Tests cover simple canonicalization and field-boundary rejection.
Circuit limits and supporting documentation
src/circuit-configs.json, skills/redacted-email-proof/scripts/redacted.mjs, README.md, skills/redacted-email-proof/SKILL.md, .agents/skills/redacted-circuit-maintenance/SKILL.md, src/lib.ts, .gitignore
The large-tier body limit changes to 48,000 bytes. Circuit hashes and references are updated, browser proving uses a 2^20-point CRS, and the guides document these limits and related maintenance details. The generated src/circuit/Prover.toml template is ignored.

Priority: ⬆️ High

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant VerifyPage
  participant Lib as src/lib.ts
  participant BodyView as bodyViewFor
  participant MaskMap as viewMaskToRawMask
  participant Circuit
  VerifyPage->>Lib: submit email and decoded-text mask
  Lib->>BodyView: build body view
  Lib->>MaskMap: map decoded mask to raw body bytes
  Lib->>Circuit: generate proof from raw signed bytes
  Circuit-->>Lib: return proof outputs
  Lib-->>VerifyPage: return masked body and decoding status
Loading

Merge Risk: ⚪ Minimal · up to 32344

The decoded-body masking paths align selections with signed bytes, and no actionable merge blocker was established. Merge after normal build and test checks.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to 32344

Signer fallback accepts valid signatures from domains other than the displayed sender, but the website does not explain that distinction when reporting authenticity. The redaction changes improve normal quoted-printable handling, while incomplete-mask and asynchronous publication behavior retain uncertainty. No signature-forgery bypass was established.

Retained concerns

  • Medium · security · inferred: Fallback newly permits the ordinary browser workflow to generate proofs for mail signed by an unrelated DKIM domain. The website still displays From alongside an authenticity result without identifying the signer, checking its published key, or explaining alignment. A valid attacker-domain signature over a chosen From address can therefore receive a misleading identity interpretation. The website's attribution omission predates this PR; the concern is its interaction with newly widened acceptance. Fallback still requires a valid signature, and the CLI supplies stronger attribution checks and mismatch warnings.
Security review details

Security Blast Radius

  • inferred — The identity concern reaches recipients interpreting shared proofs. A sender controlling an unrelated domain and its DKIM signing key can use the new fallback path for a signed message containing a chosen From address. The supported impact is misleading sender attribution; no signature forgery or additional infrastructure privilege was established.

Security Findings and Attack Paths

  • observed — The deferred mask candidate's source gap was narrowed by inspecting its full converter and caller. Undefined mask entries become reveal decisions, but the normal producer creates complete arrays and short masks already received reveal padding in the stacked base and main. This establishes fail-open semantics, not a verified PR-introduced disclosure. The canonical security input contains no retained findings.

Trust Boundaries and Controls

  • observed — Attacker-controlled signature domains are verification candidates, not automatic authorization. Each fallback attempt invokes DKIM verification. The CLI subsequently checks the proof's key hash against keys for its revealed d= and s= and reports From alignment separately. The browser proof verifier checks circuit validity but does not perform that signer-attribution step.

Resilience and Maintainability Implications

  • observed — Any byte mistakenly left revealed becomes part of the shared proof's public inputs. Omitting local mask arrays from the remote upload request does not conceal those bytes. Generation failure prevents publication in that invocation; upload failure is handled separately after a proof has been generated.

Hardening Proposals

  • proposed — Separate proof validity, authenticated signer-key attribution and From alignment in website results and presentation, preserving the distinction already exposed by the CLI.
  • proposed — Bind masks and publication to an email-generation identity, require complete defined 0/1 masks over the reconstructed body view, and reject incomplete coverage before producing public inputs. This would strengthen existing fail-open and asynchronous ownership behavior rather than remedy a demonstrated new disclosure.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 63.33% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 30 functions across 11 files. (9 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: signer-domain fallback, c=simple header support, field-span validation, and quoted-printable display with correct masking. It is specific and clear.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 63.33% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 30 functions across 11 files. (9 skipped: 9 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant