Skip to content

feat(deploy): wire dnssec_config + doh_config into beta + prod install args#3883

Open
sea-snake wants to merge 12 commits into
feat/email-recovery-frontendfrom
feat/email-recovery-deploy-scripts
Open

feat(deploy): wire dnssec_config + doh_config into beta + prod install args#3883
sea-snake wants to merge 12 commits into
feat/email-recovery-frontendfrom
feat/email-recovery-deploy-scripts

Conversation

@sea-snake
Copy link
Copy Markdown
Contributor

@sea-snake sea-snake commented May 12, 2026

Summary

  • New scripts/fetch-iana-root-anchors.bash — fetches IANA KSK trust anchors from data.iana.org/root-anchors/root-anchors.xml, filters to currently-valid entries, prints a one-line review per anchor, prompts for confirmation, and emits Candid vec record { key_tag, algorithm, digest_type, digest } ready to drop into dnssec_config.root_anchors.
  • scripts/deploy-common.bash (used by deploy-pr-to-beta + deploy-local-to-beta): build_be_install_arg can now also emit dnssec_config and doh_config alongside the existing trio. Opt-in behind --update-email-recovery-init — without the flag both fields stay opt null (preserve previous on-chain values), which is what most upgrades want once the canister has been initialized once. Companion --doh-domains <csv> overrides the curated allowlist (empty string = disable DoH entirely).
  • scripts/make-upgrade-proposal (production): didc-assist can't type 32-byte SHA-256 digests as Candid blobs, so we post-process the args.txt it produces to inject the IANA-fetched + DoH-allowlist values. Opt-in via II_UPDATE_EMAIL_RECOVERY_INIT=1. Companion II_DOH_DOMAINS=... overrides the allowlist (empty string = disable DoH entirely; the env-var presence check honours set-but-empty distinct from unset).
  • Single source of truth for the curated DoH allowlist lives in scripts/default-doh-domains.bash, sourced by both deploy-common.bash and make-upgrade-proposal so the two stay in sync. Allowlist criterion is 'DKIM resolution can't be authenticated end-to-end via DNSSEC' — which catches both apex-unsigned domains (Gmail, Outlook, iCloud, Yahoo) AND domains whose apex is signed but whose DKIM CNAMEs into unsigned territory (live.com → protection.outlook.com).

The reason we don't bundle the IANA anchors into the wasm is that they roll over every ~7 years; the next rollover becomes a one-line change in the next upgrade arg instead of a code change + recompile.

PR Stack

# PR Description Status
0 #3836 Design doc Open
1 #3838 DNSSEC verifier scaffold Open
2 #3877 DKIM verifier (RFC 6376) Open
3 #3878 DMARC alignment (RFC 7489) Open
4 #3879 DoH fallback Open
5+6 #3880 Setup flow (storage + smtp_request) Open
7 #3881 Recovery flow (delegation) Open
8 #3882 Frontend + feature flag Open
9 this PR Deploy/upgrade scripts: dnssec_config + doh_config install args Open
10 #3884 Email-recovery UX overhaul Open

See the design doc (#3836) for the architecture; the deploy-arg approach is described in §7.5 (root anchors) and §7.6 (DoH allowlist + audit criterion that catches the live.com class).

Test plan

  • ./scripts/deploy-pr-to-beta --help shows the new --update-email-recovery-init and --doh-domains flags
  • ./scripts/deploy-pr-to-beta -sa -be --dry-run <PR> (default) prints an icp install command with dnssec_config and doh_config absent — both fields stay opt null to preserve previous on-chain values
  • ./scripts/deploy-pr-to-beta -sa -be --dry-run --update-email-recovery-init <PR> populates both fields with IANA-fetched anchors + the curated DoH list, and the install arg encodes via didc encode -t '(opt InternetIdentityInit)'
  • ./scripts/deploy-pr-to-beta -sa -be --dry-run --update-email-recovery-init --doh-domains gmail.com,outlook.com <PR> produces an install arg with only those two domains in the allowlist
  • ./scripts/deploy-pr-to-beta -sa -be --dry-run --update-email-recovery-init --doh-domains '' <PR> produces an install arg with an empty allowlist (DoH disabled, DNSSEC-only)
  • ./scripts/deploy-local-to-beta -sa -be --dry-run works analogously
  • make-upgrade-proposal (without II_UPDATE_EMAIL_RECOVERY_INIT) leaves the args.txt unchanged after didc-assist
  • II_UPDATE_EMAIL_RECOVERY_INIT=1 make-upgrade-proposal populates dnssec_config + doh_config from IANA + the curated allowlist, and the encoded *.bin files validate
  • II_UPDATE_EMAIL_RECOVERY_INIT=1 II_DOH_DOMAINS='' make-upgrade-proposal produces an install arg with an empty allowed_domains vec

🤖 Generated with Claude Code

@sea-snake sea-snake requested a review from a team as a code owner May 12, 2026 11:52
@sea-snake sea-snake force-pushed the feat/email-recovery-frontend branch from cfdaa12 to 769cf1c Compare May 12, 2026 12:00
@sea-snake sea-snake force-pushed the feat/email-recovery-deploy-scripts branch from 5352b7a to f46b53a Compare May 12, 2026 12:00
@sea-snake sea-snake force-pushed the feat/email-recovery-frontend branch from 769cf1c to b40d96f Compare May 12, 2026 12:24
@sea-snake sea-snake force-pushed the feat/email-recovery-deploy-scripts branch from f46b53a to a639a12 Compare May 12, 2026 12:24
@sea-snake sea-snake force-pushed the feat/email-recovery-frontend branch from b40d96f to db81b9f Compare May 12, 2026 13:02
@sea-snake sea-snake force-pushed the feat/email-recovery-deploy-scripts branch from a639a12 to c1bad58 Compare May 12, 2026 13:02
sea-snake-translation-bot pushed a commit to sea-snake-translation-bot/internet-identity that referenced this pull request May 12, 2026
…ck) (dfinity#3877)

## Summary

PR 2 of the email-recovery stack (`docs/ongoing/email-recovery.md` §10
Phase 0). Stacks on top of PR 3838 (DNSSEC verifier). Lands a
hand-rolled RFC 6376 DKIM verifier that consumes a parsed `SmtpRequest`
plus an already-trusted DKIM TXT record and returns a per-step
`EmailVerificationStatus`.

**Note:** This PR targets `main` but includes PR 3838's commits (DNSSEC
verifier) as its base. Review the DKIM-specific changes by looking at
commits after `9bbd8717` (the last PR 3838 commit). Once PR 3838 merges,
this PR's diff will shrink to just the DKIM additions.

## Why hand-rolled

The design originally specified `mail-auth` (Stalwart's well-tested DKIM
library), but mail-auth pulls a non-optional `hickory-resolver` dep that
fails to compile for `wasm32-unknown-unknown` (transitive: tokio + mio).
Forking + patching mail-auth would be possible but creates perpetual
rebase burden. We hand-roll instead — "the right way, no shortcuts" was
the explicit guidance.

## What's in this PR

###
`src/internet_identity_interface/src/internet_identity/types/smtp.rs`
Brings forward the SMTP gateway protocol types from PoC PR 3760:
`SmtpRequest`/`SmtpResponse`/`SmtpHeader`/`SmtpMessage`/`SmtpAddress`/`SmtpEnvelope`,
the size bounds, and the input-bound validation (`format_address`
lowercases both halves; `truncate_at_char_boundary` clamps to the
previous UTF-8 boundary so a multi-byte subject can't trap the
canister). Drops postbox-specific bits (PostboxEmail,
ValidatedSmtpRequest, anchor-number parser).

### `src/internet_identity/src/dkim/`
- **`types.rs`** — Algorithm (RsaSha256, Ed25519Sha256),
HeaderCanon/BodyCanon (Relaxed, Simple),
DkimCheck/DkimCheckName/DkimCheckStatus per-step diagnostics,
EmailVerificationStatus / VerificationFailReason result shape.
- **`parse.rs`** (RFC 6376 §3.5) — DKIM-Signature header tag-list
parser. Splits structurally on `;` first then on the *first* `=` per
element, so a literal `b=` substring inside another tag's base64 doesn't
get misread as a new tag start (the bug class the PoC PR review
specifically flagged). Folded whitespace inside base64 values is
stripped before decoding. Tag names case-insensitive; duplicates
rejected.
- **`canonicalize.rs`** (§3.4.2 / §3.4.4) — relaxed header canon
(lowercase name, unfold continuations, collapse WSP+ to single SP, strip
trailing WSP, strip WSP around colon) and relaxed body canon (per-line
WSP cleanup, drop trailing empty lines, ensure non-empty output ends in
exactly one CRLF).
- **`dns_record.rs`** (§3.6.2) — DKIM TXT record parser. Tag names
case-insensitive (`P=` vs `p=` was a PoC bug), whitespace inside `p=`
tolerated (multi-chunk DNS TXT records), `t=y`/`t=s` flags honoured,
unknown tags ignored.
- **`signature.rs`** — RSA-SHA256 (RFC 5702 / RFC 8301) and
Ed25519-SHA256 (RFC 8463) signature verification on top of
`rsa`+`sha2`+`ed25519-dalek` from PR 1's deps. Enforces 1024-bit RSA
minimum per design §5.6. Ed25519 path wraps in SHA-256 per RFC 8463.
Plus `body_hash_sha256` with optional `l=` truncation per §3.4.5.
- **`verify.rs`** — orchestration. Multi-signature loop per §5.5 (accept
on first pass), tag enforcement per design §5.4 (c=relaxed/* only, x=
expiration, i= alignment with d=, k= match, t=y testing-mode), bottom-up
header selection per §5.4 when h= lists a name multiple times, b=value
blanking that's structural-position-aware so it doesn't mis-target an
internal substring.
- **`test_vectors.rs`** — `#[cfg(test)]` .eml loader + 8 end-to-end
tests against committed fixtures.

### `test_vectors/dkim/`
- 3 synthetic .eml files generated offline with dkimpy + a 2048-bit RSA
key (`relaxed/relaxed`, `relaxed/simple`, `simple/simple`).
- The matching DKIM TXT record (public key only).
- README documenting provenance — the throwaway private key is **not**
committed.

## Test plan

- [x] `cargo check -p internet_identity --target wasm32-unknown-unknown`
— clean.
- [x] `cargo test -p internet_identity --bin internet_identity dkim` —
75 tests pass (parse 14, canonicalize 18, dns_record 16, signature 7,
verify 12, end-to-end 8).
- [x] `cargo test -p internet_identity --bin internet_identity` — 313
tests pass total (was 238 before this PR; +75 DKIM, plus a few in smtp
types).
- [x] `cargo test -p internet_identity_interface --lib` — 52 tests pass
(was 42; +10 SMTP type tests).
- [x] `cargo clippy -p internet_identity --bin internet_identity --tests
-- -D warnings` — clean.
- [x] `cargo fmt --check` — clean (modulo pre-existing diffs unrelated
to this PR).

## Stack

This is PR 2 of a 12-PR series. Includes PR 3838's commits as its base;
once PR 3838 merges, the diff shrinks to just the DKIM additions.

Subsequent PRs:
- **PR 3** — DMARC alignment.
- **PR 4** — DoH outcall fallback for unsigned domains (Gmail / Outlook
/ iCloud — see the design doc §7.6 and the team Slack writeup).
- **PRs 5–9** — storage + Candid + behavior for email recovery.
- **PRs 10–12** — frontend.

## PR Stack
| # | PR | Description | Status |
|---|---|---|---|
| 0 | [dfinity#3836](dfinity#3836) |
Design doc | Open |
| 1 | [dfinity#3838](dfinity#3838) |
DNSSEC verifier scaffold | Open |
| 2 | [dfinity#3877](dfinity#3877) |
DKIM verifier (RFC 6376) | Open |
| 3 | [dfinity#3878](dfinity#3878) |
DMARC alignment (RFC 7489) | Open |
| 4 | [dfinity#3879](dfinity#3879) |
DoH fallback | Open |
| 5+6 | [dfinity#3880](dfinity#3880)
| Setup flow (storage + smtp_request) | Open |
| 7 | [dfinity#3881](dfinity#3881) |
Recovery flow (delegation) | Open |
| 8 | [dfinity#3882](dfinity#3882) |
Frontend + feature flag | Open |
| 9 | [dfinity#3883](dfinity#3883) |
Deploy/upgrade scripts: dnssec_config + doh_config | Open |
| 10 | [dfinity#3884](dfinity#3884) |
Email-recovery UX overhaul | Open |

---------

Co-authored-by: Arshavir Ter-Gabrielyan <arshavir.ter.gabrielyan@dfinity.org>
Co-authored-by: Claude <noreply@anthropic.com>
sea-snake and others added 12 commits May 13, 2026 11:07
…l args

Email recovery (PRs #3838-#3844) needs two new BE init args set on
the canister: `dnssec_config.root_anchors` (the IANA root KSKs the
DNSSEC verifier trusts) and `doh_config.allowed_domains` (the
mailbox-provider domains that can use the DoH-fallback path). This
PR plumbs both through the three deploy scripts.

What landed:

- **`scripts/fetch-iana-root-anchors.bash`** — new module, sourced
  by the other deploy scripts. `fetch_and_review_iana_root_anchors`:
  - `curl`s `data.iana.org/root-anchors/root-anchors.xml` (retry
    + bounded timeout).
  - Parses every `<KeyDigest>` block with Node, filtering to those
    whose `validFrom`/`validUntil` window contains "now" — drops
    retired keys (Kjqmt7v expired 2019) and keeps the active KSKs
    (Klajeyz key_tag=20326, Kmyv6jo key_tag=38696 today).
  - Prints a one-line human summary per entry to stderr (id +
    key_tag + algo + digest_type + digest preview + validity
    window).
  - Prompts for confirmation on /dev/tty (gracefully falls back
    to "accept default" when there's no controlling terminal,
    so CI / sandboxes don't deadlock).
  - Writes Candid `vec record { key_tag = ...; algorithm = ...;
    digest_type = ...; digest = blob "..."; }` to stdout, ready
    to drop into `dnssec_config.root_anchors`.

- **`scripts/deploy-common.bash`** (consumed by `deploy-pr-to-beta`
  and `deploy-local-to-beta`):
  - New `DEFAULT_DOH_ALLOWED_DOMAINS` list — gmail, googlemail,
    outlook, hotmail, live, icloud, me, mac, yahoo, protonmail,
    proton.me, pm.me. Adding a domain is operator config, not
    code; override per-deploy with `--doh-domains <a,b,c,...>`.
    Empty list = DNSSEC-only, no DoH fallback.
  - New `--skip-email-recovery-init` flag — when set, leave both
    fields as `opt null` (preserve previous on-chain values).
    Default is to fetch fresh anchors + use the curated DoH
    allowlist, since the staging canisters are still in the
    "needs first-run init" stage.
  - `build_be_install_arg` now emits `dnssec_config = ...` and
    `doh_config = ...` alongside the existing trio (BE id,
    BE_URL, FE_URL).

- **`scripts/make-upgrade-proposal`** (production): didc-assist's
  interactive flow doesn't know how to type a 32-byte SHA-256
  digest as Candid blob, so we *post-process* the args.txt
  it produces:
  - After didc-assist writes the file (or the trivial `(null)`
    when the user accepts the no-update default), call
    `patch_email_recovery_init_fields`.
  - The patcher uses Node to find/replace the `dnssec_config` and
    `doh_config` fields in the Candid arg text — or insert them
    inside the outermost `record { ... }` if didc-assist didn't
    emit them — with values from the same IANA fetch + DoH
    allowlist. The user can opt out with
    `II_SKIP_EMAIL_RECOVERY_INIT=1` for upgrades where the on-chain
    values are already correct, and override the DoH allowlist
    via `II_DOH_DOMAINS=...`.
  - Both staging and production paths share the patcher; encoded
    Candid round-trips through `didc encode` cleanly in all three
    edge cases tested locally: existing fields = null, fields
    absent, args.txt = `(null)`.

The reason we don't bundle the IANA anchors into the wasm is that
they roll over every ~7 years; the next rollover becomes a
one-line change in the next upgrade arg instead of a code change
+ recompile.
The original landing of these scripts had `dnssec_config` +
`doh_config` populated by default on every upgrade, with a
`--skip-email-recovery-init` escape hatch. That's the wrong
default — most upgrades happen *after* the canister has been
initialized once, and on those the values should stay as
`opt null` so the canister preserves what's already on chain.
Doing a fresh fetch on every upgrade also burns an outbound
HTTP request to data.iana.org each time the deploy script runs,
which is needless when the operator hasn't asked for it.

Flip the polarity:

- `UPDATE_EMAIL_RECOVERY_INIT` defaults to `false`. The fields
  stay `opt null` unless the operator opts in.
- The CLI flag is renamed `--skip-email-recovery-init` →
  `--update-email-recovery-init` and now opts *in* (sets the
  global to true).
- For `make-upgrade-proposal`, the env var is renamed
  `II_SKIP_EMAIL_RECOVERY_INIT=1` → `II_UPDATE_EMAIL_RECOVERY_INIT=1`,
  same polarity flip.
- Usage text and the inline doc-comments updated to match.

`--doh-domains` is unchanged — it's only meaningful alongside
`--update-email-recovery-init`, which the help text now spells out.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…DoH'

Two consistency fixes from PR #3855 review:

- `scripts/deploy-common.bash`: `--doh-domains` was using
  `[ -n "$DOH_DOMAINS_ARG" ]` to detect an explicit override,
  which collapses 'flag not passed' and 'flag passed with empty
  string' into the same branch. Documented behaviour says the
  empty list disables the DoH path entirely. Track the explicit
  flag with a separate `DOH_DOMAINS_ARG_SET` boolean and key the
  override branch off that, so `--doh-domains ""` now produces
  `allowed_domains = vec {};`.
- `scripts/make-upgrade-proposal`: same fix for the
  `II_DOH_DOMAINS` env var. Switched the "is overridden" check
  from `[ -n ... ]` to `[ -n "${II_DOH_DOMAINS+set}" ]` so an
  empty value counts as override-with-empty-list.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The original list mixed DNSSEC-signed domains in with the unsigned
ones, which is the wrong policy: domains that publish DS records
already work via the FE's native DNSSEC walk; putting them on the
DoH allowlist would let an unsigned-zone misconfiguration (parent
DS removed, recursive resolver still happy to resolve) silently
downgrade them to the canister's DoH path.

Audited 2026-05 with `dig +short DS <domain>` cross-checked against
both Google (8.8.8.8) and Cloudflare (1.1.1.1) resolvers. The
corrected list keeps only the 23 major consumer mailbox providers
that publish *no* DNSSEC; the four that did (live.com, protonmail.com,
proton.me, pm.me) were dropped, and the explicit exclusion is
documented inline so the list doesn't drift.

Removed (DNSSEC-signed):
- live.com (kept hotmail.com / outlook.com / msn.com — those don't publish DS)
- protonmail.com / proton.me / pm.me

Added (no DNSSEC, broaden coverage to other regions / providers):
- msn.com (Microsoft)
- ymail.com, aol.com (Yahoo / Verizon Media)
- zoho.com, fastmail.com, fastmail.fm, hey.com (other Western
  providers)
- yandex.com, yandex.ru, mail.ru (Russia)
- qq.com, 163.com, 126.com (China)
- naver.com, daum.net (Korea)

Both `scripts/deploy-common.bash` (DEFAULT_DOH_ALLOWED_DOMAINS) and
`scripts/make-upgrade-proposal` (default_doh_domains_for_prod) now
carry exactly the same 23-domain list.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The 23-domain DoH allowlist was spelled out twice — once as a bash
array in `deploy-common.bash` (one domain per line) and once as a
comma-separated string in `make-upgrade-proposal` — which is two
places to keep in sync next time we want to add or remove a
provider.

Move the list into a tiny shared file `scripts/default-doh-domains.bash`
that both scripts source. `make-upgrade-proposal`'s
`default_doh_domains_for_prod` now joins the array with `IFS=,` at
use time so the patcher still gets the comma-separated form it
expects. The `II_DOH_DOMAINS` and `--doh-domains` overrides are
unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
live.com's apex publishes DS, but its DKIM TXT lives behind a CNAME
chain that lands in unsigned protection.outlook.com. The DNSSEC chain
breaks end-to-end at the cross-zone CNAME, so the verifier can't
authenticate the resolution and the domain has to fall through to the
DoH path.

This is a class of failure: "apex signed" doesn't imply "DKIM signed
end-to-end". The audit criterion in the file's comment is corrected to
reflect that — what matters is whether the DKIM CNAME chain stays in
signed territory, not whether the apex publishes DS.

The genuine cross-zone-but-still-signed cases (proton.me → proton.ch,
tutanota.com → tutanota.de) stay off the list; the DNSSEC verifier's
multi-zone bundle handling (see design §7.2) handles them natively.
The off-chain SMTP gateway routes mail at a domain that varies per
deploy: id.ai on prod, beta.id.ai on beta. The canister was
hardcoding `id.ai` everywhere — recipient dispatch, the validate
query, and the user-facing label returned from prepare — so on the
beta canister mail to `register@beta.id.ai` reached the canister
but failed the recipient match and was silently dropped.

Drop the hardcoded constant. Derive the accepted mailbox domains
from `related_origins`, which is already a per-deploy arg the
deploy scripts wire through (and the same one used for security
headers + the FE's `getPrimaryOrigin`). All entries are treated
as equal aliases — recipient dispatch and the
`smtp_request_validate` query accept `register@<host>` /
`recover@<host>` for any host listed in `related_origins`. So a
prod deploy with `id.ai` + the `*.icp0.io` aliases accepts mail at
all of them; a beta deploy with `beta.id.ai` accepts that one.

Drop the `mailbox` field from `EmailRecoveryChallenge` too. The FE
already knows which origin the user is on (`window.location.hostname`),
so it pairs that with `register` / `recover` to render the label —
each tab automatically shows the alias matching the origin the
user is on, and the canister never has to single one out as
canonical.

Empty / unset `related_origins` → no domains accepted; the canister
drops every inbound recipient. Real deploys always configure this.

Tests: extended `email_recovery::smtp::tests` with `set_related_origins`
helper + 15 cases (single host, multi-alias prod, beta-only,
unknown user, wrong domain, no-origins-configured); all pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
After the d3c0cc6 change, recipient acceptance in `recipient_matches`
reads exclusively from `mailbox_domains()`, which is sourced from
`related_origins` with no fallback. The PocketIC fixtures didn't set
`related_origins`, so `register@id.ai` envelopes were silently dropped
and tests stalled in `Pending` instead of reaching `RegistrationSucceeded`.

Set `related_origins: Some(vec!["https://id.ai".into()])` in both init
sites so the `id.ai` recipient domain is accepted.
Staging A was previously deployed with the Postbox PoC
(#3760, never merged), which claimed memory
IDs 23 and 24 for `SMTP_POSTBOX` (unbounded values) and
`PUSH_SUBSCRIPTION` BTreeMaps. Our PR re-uses ID 23 for
`lookup_anchor_with_email_recovery` (32-byte fixed key,
StorableAnchorNumber value).

When `StableBTreeMap::init` runs against the existing memory page, the
BTreeMap magic still matches, so it `load`s the postbox-shaped header
and tries to read nodes against our K/V config — overflow-page math
mismatches and stable-structures traps with
`index out of bounds: len 0 but index 187` at
`btreemap/node/io.rs:58`.

Workaround: in `Storage::from_memory` (post-upgrade only), zero the
first bytes of memory IDs 23 and 24. That fails the magic check, so
`StableBTreeMap::init` calls `new` and creates a fresh BTreeMap with
our layout. Idempotent so it's safe to keep across multiple upgrades,
but it's marked TEMPORARY and should be removed once staging A has
been upgraded with this code at least once.
Staging A has been upgraded with the wipe code at least once, so the
stale `SMTP_POSTBOX` / `PUSH_SUBSCRIPTION` BTreeMap headers at memory
IDs 23 and 24 (from #3760, the Postbox PoC)
are gone. Reverts the workaround from 78d33b7 — fresh canisters and
upstream production have never had data at those IDs, so no further
deploy paths need it.
TEMPORARY (PR #3855). Adds a thread-local ring buffer and an `er_dbg!`
macro, sprinkles trace lines through the email-recovery + DoH flow,
and exposes the buffer via a new anonymous query
`email_recovery_debug_logs : () -> (vec text)` plus an
`email_recovery_debug_clear : () -> ()` companion update.

Lost on every upgrade and capped at 2000 lines, so the heap can't
grow unbounded. Anonymous because the staging tester needs to read it
without holding canister credentials. Remove together with the
`email_recovery_debug_log` Rust module and every `crate::er_dbg!`
call-site once the staging outlook.com flow is confirmed working
end-to-end.

Trace points cover:
- doh::fetch_txt — entry, allowlist gate, cache decision, per-provider
  outcall result, quorum result.
- doh::prod::outcall — per-provider URL, status, body length, body
  preview (first 60 ASCII bytes), rejection code on failure.
- email_recovery::prepare_common — entry, path picker decision,
  allowlist outcome, issued nonce.
- email_recovery::handle_smtp_request — entry envelope, recipient
  dispatch (Setup/Recovery/DROP with mailbox_domains for context),
  nonce extraction, DNSSEC vs DoH path branch.
- email_recovery::verify_setup_email_doh — DKIM/DMARC FQDNs, fetch
  outcomes, DMARC verdict, From-vs-claimed mismatch.
- email_recovery::submit_dkim_leaf — entry shape, run_submit verdict.
`semicolon_in_expressions_from_macros` (future-incompatible) trips
when `er_dbg!(...)` lands in an expression position — the trailing `;`
inside the macro body makes the expansion a statement, so a match arm
or closure that uses the macro without its own trailing semicolon
errors.

Removing the body's `;` keeps the expansion an expression; call-sites
that want statement form keep their own trailing `;` outside the
invocation, which is the idiomatic shape for `println!`-family
macros.
@sea-snake sea-snake force-pushed the feat/email-recovery-frontend branch from db81b9f to 728bf87 Compare May 13, 2026 11:25
@sea-snake sea-snake force-pushed the feat/email-recovery-deploy-scripts branch from c1bad58 to de9e3d8 Compare May 13, 2026 11:25
aterga added a commit that referenced this pull request May 13, 2026
…of email-recovery stack) (#3878)

## Summary

PR 3 of the email-recovery stack (`docs/ongoing/email-recovery.md` §6).
Stacks on top of #3877 (DKIM verifier). Lands a hand-rolled DMARC
alignment check and reshapes the verifier API: `dkim::verify_dkim`
becomes a DKIM-only primitive, and the new `dmarc::verify_email` is the
public top-level entry point that produces the combined
`EmailVerificationStatus`.

**Note:** This PR targets `main` but includes PRs 1+2's commits as its
base. Review the DMARC-specific changes by looking at commits on top of
`ec371aae3` (PR 2's tip). Once PRs 1+2 merge, this PR's diff shrinks to
just the DMARC additions.

## What's in this PR

### `src/internet_identity/src/dmarc/`

- **`types.rs`** — `DmarcOutcome` (Aligned / Misaligned / NoRecord /
Malformed), `DmarcPolicy` (None / Quarantine / Reject), `AlignmentMode`
(Strict / Relaxed), `DmarcRecord`, plus the combined
`EmailVerificationStatus` that carries both DKIM diagnostics and the
DMARC outcome on success.
- **`parse.rs`** (RFC 7489 §6.3) — DMARC TXT record parser. Enforces
`v=DMARC1` must be first, `p=` must be one of {none, quarantine,
reject}, `pct=` 0..=100, rejects duplicate tags, ignores unknown /
reporting tags. 12 unit tests.
- **`from_header.rs`** (RFC 5322 / RFC 7489 §3.1.1) — single-mailbox
From-header parser. Accepts bare addr-spec, name-addr, and
quoted-display-name forms; rejects zero/multiple From: headers,
address-lists, group syntax. Tolerates comma/colon inside quoted display
names. 16 unit tests.
- **`alignment.rs`** — strict (exact match) + relaxed (exact match OR
label-aligned subdomain in either direction). Stricter than
RFC-compliant relaxed alignment because we deliberately don't consult
the PSL — design doc §6.4 documents the trust + asymmetric-failure-mode
reasoning. The dot anchor on the subdomain check prevents
`evilexample.com` from aliasing `example.com`. 8 unit tests.
- **`verify.rs`** — orchestration. DKIM first; on failure, surface the
DKIM reason verbatim. On DKIM pass, parse From and check DMARC
alignment. Accepted iff Aligned, OR NoRecord with `dkim_domain ==
from_domain`. 8 unit tests.
- **`test_vectors.rs`** — 5 end-to-end tests reusing PR 2's synthetic
.eml fixtures.

### `src/internet_identity/src/dkim/types.rs` (rename + new variants)

- Renamed `EmailVerificationStatus` → `DkimVerifyResult` (DKIM-only).
The combined verdict moved to `dmarc::EmailVerificationStatus` so it can
carry the `DmarcOutcome`.
- Added `MalformedFromHeader(String)`, `DmarcMalformed(String)`,
`DmarcMisaligned` to `VerificationFailReason`.

### `src/internet_identity/src/dkim/mod.rs`

- Re-exports `verify` as `verify_dkim` so downstream callers (the dmarc
layer) don't have to deal with both a `dkim::verify` and `dmarc::verify`
in scope at the same time.

## Test plan

- [x] `cargo check -p internet_identity --target wasm32-unknown-unknown`
— clean.
- [x] `cargo test -p internet_identity --bin internet_identity dmarc` —
49 tests pass (12 parse + 16 from_header + 8 alignment + 8 verify + 5
e2e).
- [x] `cargo test -p internet_identity --bin internet_identity` — 365
tests pass total (was 313 with PR 2; +49 dmarc + 3 small reshape
adjustments).
- [x] `cargo clippy -p internet_identity --bin internet_identity --tests
-- -D warnings` — clean.
- [x] `cargo fmt --check` — clean (modulo pre-existing unrelated diffs).

## PR Stack
| # | PR | Description | Status |
|---|---|---|---|
| 0 | [#3836](#3836) |
Design doc | Open |
| 1 | [#3838](#3838) |
DNSSEC verifier scaffold | Open |
| 2 | [#3877](#3877) |
DKIM verifier (RFC 6376) | Open |
| 3 | [#3878](#3878) |
DMARC alignment (RFC 7489) | Open |
| 4 | [#3879](#3879) |
DoH fallback | Open |
| 5+6 | [#3880](#3880)
| Setup flow (storage + smtp_request) | Open |
| 7 | [#3881](#3881) |
Recovery flow (delegation) | Open |
| 8 | [#3882](#3882) |
Frontend + feature flag | Open |
| 9 | [#3883](#3883) |
Deploy/upgrade scripts: dnssec_config + doh_config | Open |
| 10 | [#3884](#3884) |
Email-recovery UX overhaul | Open |

---------

Co-authored-by: Arshavir Ter-Gabrielyan <arshavir.ter.gabrielyan@dfinity.org>
Co-authored-by: Claude <noreply@anthropic.com>
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