Skip to content

feat(email-recovery): frontend wizard + EMAIL_RECOVERY flag (beta.id.ai default-on)#3882

Open
sea-snake wants to merge 22 commits into
feat/email-recovery-flowfrom
feat/email-recovery-frontend
Open

feat(email-recovery): frontend wizard + EMAIL_RECOVERY flag (beta.id.ai default-on)#3882
sea-snake wants to merge 22 commits into
feat/email-recovery-flowfrom
feat/email-recovery-frontend

Conversation

@sea-snake
Copy link
Copy Markdown
Contributor

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

Summary

What lands here

Surface What
Setup wizard lib/components/wizards/setupEmailRecovery/ — enter address → send magic email (with countdown) → done. Polls email_recovery_status with 1→5s exponential backoff.
Recovery wizard lib/components/wizards/recoverWithEmail/ — anonymous, ends with a SignedDelegation. Generates an ECDSA session keypair locally, drives prepare_delegationstatusget_delegation, hands the result to the host page.
Manage page New email-recovery card alongside the existing phrase card. Active state shows bound address + last-used + Replace/Remove.
Recover sign-in Second button "Recover with email" alongside the existing phrase entry.
DNSSEC assembly lib/utils/dnssec/ — wire-format DoH client (RFC 8484), DNS message parser, RRSIG parser, key-tag computation (RFC 4034 App. B), and a chain walker that bottom-up assembles a DnsProofBundle. Uses RRSIG signer_name to drive zone discovery so arbitrary depths work. Returns undefined for unsigned domains → canister falls through to its DoH path.
Backend IdentityInfo gains an email_recovery field so the manage-page card renders correct active/inactive state on first load without a separate query.

Tests

  • tests/e2e-playwright/routes/emailRecovery.spec.ts — feature-flag gating + wizard mount on both manage and recovery pages.
  • All 446 backend unit tests pass; npm run check clean.

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 this PR Frontend + feature flag Open
9 #3883 Deploy/upgrade scripts: dnssec_config + doh_config Open
10 #3884 Email-recovery UX overhaul Open

Notes for reviewers

  • The full magic-email-driven happy path isn't covered by the Playwright tests yet — driving smtp_request with a DKIM signature requires standing up a gateway-side test rig that's out of scope here. The canister-side PocketIC integration tests cover that path. The new spec validates the FE wiring (flag gating, dialog mount).
  • DNSSEC walker uses wire-format DoH (Accept: application/dns-message) because the JSON variant returns RDATA in presentation form which can't be re-encoded back to the canonical bytes RRSIG verification needs.
  • The flag's init callback runs at hooks.client.ts time, so beta.id.ai users get the wizard surface without any manual configuration.

🤖 Generated with Claude Code

E2E tests

The wizard surface (emailRecovery.spec.ts) now covers two layers:

  • Feature-flag gating + dialog open/close (the original wiring tests).
  • Real DNSSEC + DKIM happy path: synthesises a fresh DNSSEC chain + RSA-2048 DKIM keypair, intercepts the FE's DoH lookups via page.route(), drives the wizard to the magic-email step, and submits the canister-issued nonce email through smtp_request. Both setup and recovery legs run in the same test. CI computes the trust anchor on the fly via renderDnssecTestAnchor.mjs.

Changes during review

FE review fixes in 98a9ad80: use RecoveryReady.anchor_number, add emailRecovery authMethod variant, drop dynamic <Trans>, replace AbortSignal.timeout with AbortController, type wrappers against generated Candid types + throwCanisterError.

Test fixups: 64151289 btn migration, c7a8c03a eslint, fcb1a3e7 unused constants, 006f2f99 prettier, b6c28bc1 recoveryPage fixture button label, 09864b1a unique From address per fixture.

@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-flow branch from 52ed1eb to c85297a Compare May 12, 2026 12:00
@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-flow branch from c85297a to 16fc9bc Compare May 12, 2026 12:24
@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-flow branch from 16fc9bc to 6cbfe79 Compare May 12, 2026 13:01
@sea-snake sea-snake force-pushed the feat/email-recovery-frontend branch from b40d96f to db81b9f 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 15 commits May 13, 2026 11:07
PR 3 of the email-recovery stack (docs/ongoing/email-recovery.md §6).
Stacks on top of PR 2 (DKIM verifier) and reshapes the verifier API:
dkim::verify_dkim now returns a DKIM-only DkimVerifyResult, and the new
dmarc::verify_email is the public top-level entry point that produces
the combined EmailVerificationStatus.

New module src/internet_identity/src/dmarc/:
- types.rs: DmarcOutcome (Aligned / Misaligned / NoRecord / Malformed),
  DmarcPolicy, AlignmentMode, DmarcRecord, plus the combined
  EmailVerificationStatus that carries both the DKIM diagnostic and
  the DMARC outcome on success.
- parse.rs: DMARC TXT record parser per RFC 7489 §6.3. Enforces
  v=DMARC1 must be first, p= must be one of {none, quarantine, reject},
  pct= 0..=100, rejects duplicate tags, ignores unknown tags. 12 unit
  tests.
- from_header.rs: RFC 5322 single-mailbox From: 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 — see design doc §6.4. 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.

dkim/types.rs:
- Renamed EmailVerificationStatus -> DkimVerifyResult (DKIM-only).
  The combined verdict moved to dmarc::EmailVerificationStatus so it
  can carry the DmarcOutcome.
- Added MalformedFromHeader, DmarcMalformed, DmarcMisaligned to
  VerificationFailReason.

44 DMARC tests pass (12 parse + 16 from_header + 8 alignment + 8 verify),
on top of the existing 78 DKIM tests. Wasm32 build clean.
Brings the recovery half (`prepare_delegation` → `smtp_request` →
`submit_dkim_leaf` (DNSSEC) or finished synchronously (DoH) →
`get_delegation`) onto the new two-phase storage-and-smtp base. The
recovery flow shares all the heavy lifting with the setup flow —
prepare validation, skeleton chain caching, DKIM-signature parsing,
body-hash check, partial-verification stash, leaf admission, DKIM
crypto verify, DMARC alignment — and only diverges at finalization.

Setup and recovery now share:
- One `prepare_common` validation core. Setup parks
  `PendingKind::Register{anchor}`; recovery parks
  `PendingKind::Recover{session_pk}` after capping `session_pk` to
  `MAX_SESSION_KEY_BYTES = 1024`.
- One `smtp_request` dispatcher. Recipient (`register@id.ai` vs
  `recover@id.ai`) is cross-checked against the entry's
  `PendingKind` so a forged `to:` can't run the wrong flow.
- One DNSSEC partial-verification path: parse → bh= → digest →
  cache → flip `NeedDkimLeaf{selector}`. Recovery is no different
  from setup at this step.
- One `submit_dkim_leaf` path: leaf admission against the cached
  zone DNSKEY, DKIM crypto verify (prehash), DMARC alignment.

What diverges at finalization:
- **Setup**: `bind_credential(anchor, address)` writes the
  `EmailRecoveryCredential` to the named anchor.
- **Recovery**: `stamp_recovery_delegation` looks up the anchor
  from the verified `From:` via the reverse-address index
  (memory ID 24, hashed-key map), derives the seed
  `H(salt || "email-recovery" || lowercase(address) || anchor)`,
  adds the canister signature for `(session_pk, expiration)`, and
  caches a `RecoveryOutcome { user_key, expiration, anchor_number,
  seed }` on the pending entry. Polling then surfaces
  `RecoveryReady{user_key, expiration, anchor_number}`.

This finalization fork lives in two places:
- `smtp.rs` for the DoH path (verification finishes synchronously
  inside `smtp_request`).
- `submit_leaf.rs` for the DNSSEC path (verification finishes
  inside `email_recovery_submit_dkim_leaf` after the FE submits
  the leaf).

Both call `stamp_recovery_delegation` on the recovery branch and
`bind_credential` on the setup branch.

Other scoped pieces:
- `EmailRecoveryStatus::RecoveryReady` carries `anchor_number` so
  the FE seeds its auth store without a separate lookup.
- `PendingChallenge.recovery_outcome: Option<RecoveryOutcome>`
  caches the seed + anchor + user_key for `get_delegation`.
- `email_recovery_get_delegation(args)` query mirrors
  `openid_get_delegation` in shape — uses the cached seed to
  retrieve the canister signature.
- `recovery_seed_for_nonce(nonce)` exposes the cached seed to
  `get_delegation` without re-deriving from the anchor.
- Reverse address index: `SHA-256(lowercase(address)) →
  AnchorNumber`, memory ID 24, kept in sync with anchor writes.
- `IdentityInfo.email_recovery: Option<EmailRecoveryCredential>`
  surfaced on `identity_info` so the manage page renders the
  recovery-email card without a second canister call.
- `check_authorization` recognises an additional principal kind:
  delegations rooted in `H(salt || "email-recovery" ||
  lowercase(address) || anchor)` are accepted as authenticating
  the matching anchor. After a recovery completes the FE's
  session keypair holds such a delegation; this lets the user
  call `identity_info` and the rest of the authenticated surface
  immediately, without re-mint.
- Archive operations: payload-free `AddEmailRecovery` /
  `RemoveEmailRecovery` variants on `Operation` so audit
  consumers can answer "who changed their recovery email when?"
  without leaking the address (§8.2).
- Activity-stats counter for email-recovery delegation issuance,
  alongside the existing per-issuer OpenID counter.
- `archive.did`, `internet_identity.did` updated; FE bindings
  regenerated.

444 unit tests pass (3 new). The integration-test happy-path
fixture still needs a follow-up rewrite to exercise the new
two-phase shape end-to-end (prepare → smtp_request[NeedDkimLeaf]
→ submit_dkim_leaf), but compiles clean against the new types.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Brings the recovery half (`prepare_delegation` → `smtp_request` →
`submit_dkim_leaf` (DNSSEC) or finished synchronously (DoH) →
`get_delegation`) onto the new two-phase storage-and-smtp base. The
recovery flow shares all the heavy lifting with the setup flow —
prepare validation, skeleton chain caching, DKIM-signature parsing,
body-hash check, partial-verification stash, leaf admission, DKIM
crypto verify, DMARC alignment — and only diverges at finalization.

Setup and recovery now share:
- One `prepare_common` validation core. Setup parks
  `PendingKind::Register{anchor}`; recovery parks
  `PendingKind::Recover{session_pk}` after capping `session_pk` to
  `MAX_SESSION_KEY_BYTES = 1024`.
- One `smtp_request` dispatcher. Recipient (`register@id.ai` vs
  `recover@id.ai`) is cross-checked against the entry's
  `PendingKind` so a forged `to:` can't run the wrong flow.
- One DNSSEC partial-verification path: parse → bh= → digest →
  cache → flip `NeedDkimLeaf{selector}`. Recovery is no different
  from setup at this step.
- One `submit_dkim_leaf` path: leaf admission against the cached
  zone DNSKEY, DKIM crypto verify (prehash), DMARC alignment.

What diverges at finalization:
- **Setup**: `bind_credential(anchor, address)` writes the
  `EmailRecoveryCredential` to the named anchor.
- **Recovery**: `stamp_recovery_delegation` looks up the anchor
  from the verified `From:` via the reverse-address index
  (memory ID 24, hashed-key map), derives the seed
  `H(salt || "email-recovery" || lowercase(address) || anchor)`,
  adds the canister signature for `(session_pk, expiration)`, and
  caches a `RecoveryOutcome { user_key, expiration, anchor_number,
  seed }` on the pending entry. Polling then surfaces
  `RecoveryReady{user_key, expiration, anchor_number}`.

This finalization fork lives in two places:
- `smtp.rs` for the DoH path (verification finishes synchronously
  inside `smtp_request`).
- `submit_leaf.rs` for the DNSSEC path (verification finishes
  inside `email_recovery_submit_dkim_leaf` after the FE submits
  the leaf).

Both call `stamp_recovery_delegation` on the recovery branch and
`bind_credential` on the setup branch.

Other scoped pieces:
- `EmailRecoveryStatus::RecoveryReady` carries `anchor_number` so
  the FE seeds its auth store without a separate lookup.
- `PendingChallenge.recovery_outcome: Option<RecoveryOutcome>`
  caches the seed + anchor + user_key for `get_delegation`.
- `email_recovery_get_delegation(args)` query mirrors
  `openid_get_delegation` in shape — uses the cached seed to
  retrieve the canister signature.
- `recovery_seed_for_nonce(nonce)` exposes the cached seed to
  `get_delegation` without re-deriving from the anchor.
- Reverse address index: `SHA-256(lowercase(address)) →
  AnchorNumber`, memory ID 24, kept in sync with anchor writes.
- `IdentityInfo.email_recovery: Option<EmailRecoveryCredential>`
  surfaced on `identity_info` so the manage page renders the
  recovery-email card without a second canister call.
- `check_authorization` recognises an additional principal kind:
  delegations rooted in `H(salt || "email-recovery" ||
  lowercase(address) || anchor)` are accepted as authenticating
  the matching anchor. After a recovery completes the FE's
  session keypair holds such a delegation; this lets the user
  call `identity_info` and the rest of the authenticated surface
  immediately, without re-mint.
- Archive operations: payload-free `AddEmailRecovery` /
  `RemoveEmailRecovery` variants on `Operation` so audit
  consumers can answer "who changed their recovery email when?"
  without leaking the address (§8.2).
- Activity-stats counter for email-recovery delegation issuance,
  alongside the existing per-issuer OpenID counter.
- `archive.did`, `internet_identity.did` updated; FE bindings
  regenerated.

444 unit tests pass (3 new). The integration-test happy-path
fixture still needs a follow-up rewrite to exercise the new
two-phase shape end-to-end (prepare → smtp_request[NeedDkimLeaf]
→ submit_dkim_leaf), but compiles clean against the new types.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Brings the recovery half (`prepare_delegation` → `smtp_request` →
`submit_dkim_leaf` (DNSSEC) or finished synchronously (DoH) →
`get_delegation`) onto the new two-phase storage-and-smtp base. The
recovery flow shares all the heavy lifting with the setup flow —
prepare validation, skeleton chain caching, DKIM-signature parsing,
body-hash check, partial-verification stash, leaf admission, DKIM
crypto verify, DMARC alignment — and only diverges at finalization.

Setup and recovery now share:
- One `prepare_common` validation core. Setup parks
  `PendingKind::Register{anchor}`; recovery parks
  `PendingKind::Recover{session_pk}` after capping `session_pk` to
  `MAX_SESSION_KEY_BYTES = 1024`.
- One `smtp_request` dispatcher. Recipient (`register@id.ai` vs
  `recover@id.ai`) is cross-checked against the entry's
  `PendingKind` so a forged `to:` can't run the wrong flow.
- One DNSSEC partial-verification path: parse → bh= → digest →
  cache → flip `NeedDkimLeaf{selector}`. Recovery is no different
  from setup at this step.
- One `submit_dkim_leaf` path: leaf admission against the cached
  zone DNSKEY, DKIM crypto verify (prehash), DMARC alignment.

What diverges at finalization:
- **Setup**: `bind_credential(anchor, address)` writes the
  `EmailRecoveryCredential` to the named anchor.
- **Recovery**: `stamp_recovery_delegation` looks up the anchor
  from the verified `From:` via the reverse-address index
  (memory ID 24, hashed-key map), derives the seed
  `H(salt || "email-recovery" || lowercase(address) || anchor)`,
  adds the canister signature for `(session_pk, expiration)`, and
  caches a `RecoveryOutcome { user_key, expiration, anchor_number,
  seed }` on the pending entry. Polling then surfaces
  `RecoveryReady{user_key, expiration, anchor_number}`.

This finalization fork lives in two places:
- `smtp.rs` for the DoH path (verification finishes synchronously
  inside `smtp_request`).
- `submit_leaf.rs` for the DNSSEC path (verification finishes
  inside `email_recovery_submit_dkim_leaf` after the FE submits
  the leaf).

Both call `stamp_recovery_delegation` on the recovery branch and
`bind_credential` on the setup branch.

Other scoped pieces:
- `EmailRecoveryStatus::RecoveryReady` carries `anchor_number` so
  the FE seeds its auth store without a separate lookup.
- `PendingChallenge.recovery_outcome: Option<RecoveryOutcome>`
  caches the seed + anchor + user_key for `get_delegation`.
- `email_recovery_get_delegation(args)` query mirrors
  `openid_get_delegation` in shape — uses the cached seed to
  retrieve the canister signature.
- `recovery_seed_for_nonce(nonce)` exposes the cached seed to
  `get_delegation` without re-deriving from the anchor.
- Reverse address index: `SHA-256(lowercase(address)) →
  AnchorNumber`, memory ID 24, kept in sync with anchor writes.
- `IdentityInfo.email_recovery: Option<EmailRecoveryCredential>`
  surfaced on `identity_info` so the manage page renders the
  recovery-email card without a second canister call.
- `check_authorization` recognises an additional principal kind:
  delegations rooted in `H(salt || "email-recovery" ||
  lowercase(address) || anchor)` are accepted as authenticating
  the matching anchor. After a recovery completes the FE's
  session keypair holds such a delegation; this lets the user
  call `identity_info` and the rest of the authenticated surface
  immediately, without re-mint.
- Archive operations: payload-free `AddEmailRecovery` /
  `RemoveEmailRecovery` variants on `Operation` so audit
  consumers can answer "who changed their recovery email when?"
  without leaking the address (§8.2).
- Activity-stats counter for email-recovery delegation issuance,
  alongside the existing per-issuer OpenID counter.
- `archive.did`, `internet_identity.did` updated; FE bindings
  regenerated.

444 unit tests pass (3 new). The integration-test happy-path
fixture still needs a follow-up rewrite to exercise the new
two-phase shape end-to-end (prepare → smtp_request[NeedDkimLeaf]
→ submit_dkim_leaf), but compiles clean against the new types.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Rust `Operation` enum gained `AddEmailRecovery` and
`RemoveEmailRecovery` in the previous commit, but the archive
canister's .did didn't follow — `check_candid_interface_compatibility`
in archive's main.rs caught the divergence (it compares the .did
against `__export_service`).

Mirror both variants in archive.did with the same no-payload shape
and a brief inline comment matching the Rust doc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ai default-on)

Frontend wiring for the email-recovery setup and recovery flows.
Stacks on PR #3843; gated by a new EMAIL_RECOVERY feature flag that
defaults to false everywhere except beta.id.ai.

- **Feature flag** (`EMAIL_RECOVERY` in `lib/state/featureFlags.ts`):
  default false; init callback flips it to true when
  `window.location.hostname === "beta.id.ai"`. Same temporary-override
  pattern as `GUIDED_UPGRADE`, so a manual `set()` from the console
  takes precedence on any host.
- **Setup wizard** (`lib/components/wizards/setupEmailRecovery/`):
  enter address → send magic email (with countdown to expiry) →
  done. Polls `email_recovery_status` until terminal state with
  1→5s exponential backoff. Failure variants map to user-friendly
  copy.
- **Recovery wizard** (`lib/components/wizards/recoverWithEmail/`):
  anonymous, ends with a `SignedDelegation`. Generates an ECDSA
  session keypair locally, calls `prepare_delegation`, polls
  status, calls `get_delegation`, and hands the result to the
  host page so it can build a `DelegationIdentity` and redirect
  to manage.
- **Manage page card** (`recovery/components/{Active,Inactive}EmailRecovery.svelte`):
  active/inactive states alongside the existing recovery-phrase
  card. Active state shows the bound address + last-used + Replace/
  Remove. Inactive state shows "Add email".
- **Recover sign-in page**: second button "Recover with email"
  alongside the existing phrase entry.
- **DNSSEC bundle assembly** (`lib/utils/dnssec/`): wire-format
  DoH client (POST/GET application/dns-message), DNS message
  parser, RRSIG parser, key-tag computation, and a chain walker
  that bottom-up assembles a `DnsProofBundle` matching the
  canister-side verifier's shape. Uses the RRSIG signer_name field
  to drive zone discovery, so it handles arbitrary-depth domains
  not just `<TLD>.<root>` two-level setups. Returns undefined for
  unsigned domains so the canister falls through to its DoH path.
  Selector probing covers the common patterns (Gmail date-style,
  Microsoft selector1/2, Apple sig1, Proton, generic).
- **Backend tweak**: `IdentityInfo` gains an `email_recovery: opt
  EmailRecoveryCredential` field so the manage-page card can
  render the right active/inactive state on first load without a
  separate query.

`tests/e2e-playwright/routes/emailRecovery.spec.ts`:
- Feature-flag gating: card hidden on manage page when off; button
  hidden on recovery page when off.
- Wizard surface: clicking "Add email" / "Recover with email"
  opens the right dialog with the correct heading.

The full magic-email-driven happy path is *not* covered here — it
needs the local canister deploy to carry a DoH allowlist for the
test domain *and* a way to push a DKIM-signed `smtp_request` so the
status flips. Both are real work; the canister-side PocketIC
integration tests already cover that path. The new spec validates
the FE wiring (flag gating, dialog mount) so a regression in the
wizard's wiring is caught here without rebuilding the gateway-side
test rig.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Addresses review comments on the wizard / recovery page:

- Use the `anchor_number` now carried on `RecoveryReady` to seed
  the auth store, instead of the (incorrect)
  `lookup_caller_identity_by_recovery_phrase` call.
- Add a dedicated `emailRecovery` `authMethod` variant on the
  authentication store rather than masquerading as
  `recoveryPhrase` (semantically misleading for telemetry/UI).
- Drop `<Trans>{dynamic}</Trans>` in `FailedView` and the
  `ActiveEmailRecovery` card — dynamic-only Trans bodies have no
  compile-time `id`/`message` and crash at runtime.
- Replace `AbortSignal.timeout` in `lib/utils/dnssec/doh.ts` with
  the `AbortController + setTimeout` pattern the rest of the FE
  uses (`ssoDiscovery.ts`).
- Type `prepareAddEmail` / `prepareEmailDelegation` /
  `getEmailDelegation` against the generated Candid types and
  route errors through `throwCanisterError` instead of `any +
  JSON.stringify`.
Builds a fresh DNSSEC chain + DKIM keypair per test, intercepts
the FE's DoH lookups via Playwright `page.route()`, and submits
the canister-issued nonce email through `smtp_request`. Drives
both the setup and recovery legs against the local II canister so
the full DNSSEC verifier and DKIM/DMARC verifier paths are
exercised end-to-end without touching the real network.

The local canister is deployed with a trust anchor matching the
signer's deterministic seed:

- `dnssecTestSigner.ts` — port of the Rust `dnssec_signer` from
  the integration test. Single-link chain (root → leaf zone),
  Ed25519, deterministic seed `[1u8; 32]`.
- `dkimTestSigner.ts` — port of the Rust `dkim_signer`.
  RSA-2048, relaxed/relaxed canonicalization, RSA-SHA256.
- `dohRouteInterceptor.ts` — wire-format DoH responder that
  serves the test chain's bytes back to Cloudflare/Google DoH
  endpoint URLs.
- `renderDnssecTestAnchor.mjs` — derives the candid
  `dnssec_config` record from the signer's seed; CI runs it at
  install time so the workflow has no hardcoded digest. The
  same digest is inlined into `local_test_arg.did` for local
  `icp deploy` runs (with a comment pointing at the script).

CI: the install args in `canister-tests.yml` now splice in the
script-derived `dnssec_config`. The new spec lives next to the
existing `emailRecovery.spec.ts` (gating-only) and runs in the
existing matrix shards without further config.
`Button.svelte` is gone on `main` (migrated to a `btn` utility class
on plain `<button>` elements); this PR's branch hasn't merged that
change yet. The new email-recovery wizard files were copy-pasting
the `<Button>` import that worked on the branch but not against the
PR's merged state, so the docker frontend build (and svelte-check)
failed on every CI run after the migration landed.

Migrate the 7 new wizard views to the post-migration shape, matching
how `InactiveRecoveryPhrase` / `ActiveRecoveryPhrase` look on main.
Existing files that still import `<Button>` on this branch are
intentionally untouched — main's btn-class versions win on merge.
ESLint flagged six new violations the FE PR's pre-existing checks
don't accept:

- `prepareEmailDelegation` / `getEmailDelegation` /
  `prepareAddEmail` / `emailRecoveryStatus` / `statusEmailRecovery`
  were declared `async` but only forwarded a `.then()` chain — drop
  the redundant `async`.
- `dnssecTestSigner.rootAnchor` was async but doesn't `await`; same
  fix.
- `dkimTestSigner.signEmail` used `||` to default `fromDomain` /
  `toDomain` — explicit empty-string check instead, which the
  `strict-boolean-expressions` rule requires.
The interceptor reads rtype from the SignedRRset bundle directly and
only constructs RRSIG records itself. ESLint --max-warnings 0 flagged
the unused TYPE_TXT / TYPE_DNSKEY / TYPE_DS constants.
CI runs prettier --check; the new FE files weren't all formatted to
the project's prettier config. Auto-format them.
The canister keeps a global `address → anchor` reverse index, so two
tests reusing the same address against the same canister deployment
collide on `bind_credential_to_anchor`'s same-anchor check after the
first run. CI hides this because each shard runs against its own
canister, but local serial reruns surface the collision.

Generate `alice-<rand>@<test_domain>` per fixture instance so the
test stays isolated from prior bindings.
The recovery sign-in page renamed the "Get started" button to
"Recover with phrase" when the email-recovery sibling was added
(see `frontend wizard + EMAIL_RECOVERY flag` commit). The fixture
in `tests/e2e-playwright/fixtures/recoveryPage.ts` was missed —
every recovery-phrase wizard test that goes through `/recovery`
times out waiting for the old label.

Match the new label.
Drops `discoverSelector` and `SELECTOR_CANDIDATES` entirely — the
DKIM selector is now read from the email itself by the canister and
returned to the FE via the `NeedDkimLeaf { selector }` mid-flow
status. The FE then walks just that one DNSSEC leaf and submits it
via `email_recovery_submit_dkim_leaf`. No more probing.

What landed:

- **`lib/utils/dnssec/`** split:
  - `walkSkeletonChain(domain, wantDmarc)` — chain rooted at IANA
    + optional DMARC leaf at `_dmarc.<domain>`. No DKIM.
  - `walkDkimLeaf(domain, selector)` — chain + DKIM TXT leaf for
    the post-email selector. Returns the bundle to submit and
    the leaf separately.
  - Shared `walkUpFromLeafZone` helper — both walks share the
    bottom-up RRSIG-walk to root.
  - Public re-exports renamed to `assembleSkeleton` /
    `assembleDkimLeaf`. `discoverSelector` and the
    `SELECTOR_CANDIDATES` table are gone.

- **`SetupEmailRecoveryWizard`** + **`RecoverWithEmailWizard`**:
  - Drop `discoverSelector` call site.
  - Drop the `selector` field from the `EmailRecoveryDnsInput`
    sent to prepare.
  - Polling loop watches for `NeedDkimLeaf { selector }`:
    when it lands, walk the DKIM leaf (`assembleDkimLeaf`) and
    call `submitDkimLeaf({ nonce, dkim_leaf })`. The submission
    response carries the post-call status (`RegistrationSucceeded`
    for setup, `RecoveryReady{...}` for recovery), so the FE can
    finish the wizard one round-trip earlier on the happy path.
  - Failed-status copy: drop `SelectorMismatch`; add the new
    `DkimLeafMismatch` and `NoDkimLeafExpected` variants.

- **Host pages** (`recovery/+page.svelte` and the manage page's
  recovery sub-route): add an anonymous-actor wrapper for
  `email_recovery_submit_dkim_leaf` and pass it as the new
  `submitDkimLeaf` prop to the corresponding wizard.

- **Test fixtures** (`dnssecTestSigner` + `dohRouteInterceptor` +
  `emailRecovery` fixture):
  - `buildChain` returns `{ anchor, skeleton, dkimLeaf, dmarcLeaf }`
    separately so callers compose single-leaf bundles for whichever
    phase they're testing.
  - `installDohInterceptor(page, bundle, extraLeaves[])` —
    accepts extra leaves on top of the skeleton bundle's chain
    so the DoH layer answers DKIM-leaf queries from the
    post-email walk too.
  - The `EmailRecoveryFixtures` ctx now holds skeleton + dkimLeaf
    + signer; the interceptor publishes both.

FE type-check passes (0 errors). Canister-side unit tests still
pass (444). The FE wizard end-to-end will exercise the full
two-phase path once the e2e DoH interceptor's now-extra-leaf hookup
is run by the playwright suite.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
sea-snake and others added 7 commits May 13, 2026 11:07
`assembleSkeleton` and `assembleDkimLeaf` are thin wrappers that
just return the inner walker's promise — there's nothing to
`await`. ESLint's `require-await` rule (enforced as error in CI)
flagged both. Drop the redundant `async` keywords; the call-site
behaviour is identical (returning a `Promise<…>` either way).

Also: trailing rebase fmt drift across `pending.rs` and
`api_v2.rs` — `cargo fmt` noop, just whitespace.
CI's prettier --check flagged three files modified by the FE
two-phase rewrite. Re-apply prettier formatting; logic unchanged.
Real-world DKIM resolution often crosses zone boundaries via CNAME —
proton.me → proton.ch, tutanota.com → tutanota.de, M365 custom
domains, etc. DNSSEC signatures don't span zones, so each hop in
the chain must be authenticated by its own zone's DNSKEY.

The FE walker now follows CNAMEs at submit time and supplies any
*additional* delegation chains for zones that weren't already
covered by the prepare bundle.

Public surface:

- `assembleSkeleton(domain, wantDmarc)` — same purpose, returns the
  new bundle shape (`chains`, `hops`).
- `assembleDkimResolution(domain, selector)` — replaces
  `assembleDkimLeaf`. Returns `{ hops, extraChains }` for the new
  `email_recovery_submit_dkim_leaf` API.

Internals (chain.ts):

- `walkCnameChain(name)` — fetches each hop one at a time, picking
  TXT or CNAME based on what the zone publishes. Loops, oversize
  chains, and CNAMEs into unsigned territory (live.com case) all
  surface as `undefined`, which the caller turns into a no-bundle
  prepare so the canister falls through to DoH.
- `walkChainForZone(zoneBytes)` — walks a delegation chain root
  → … → zone, used to populate `extraChains` for any new signing
  zone the resolution crossed into.

Generated bindings regenerated from the updated `.did` via `npm run
generate:types && npm run generate:js`.
The off-chain SMTP gateway calls a query method at RCPT TO time,
before it pulls the message body from the sending MTA, to decide
whether to accept the connection at all. The PoC defined this as
\`smtp_request_validate\` and gated acceptance on the user-part
parsing as a numeric anchor number — so on the email-recovery
deploy, the gateway rejects \`register@id.ai\` and
\`recover@id.ai\` outright, and \`smtp_request\` is never called.

Bring the query back, with the email-recovery-specific shape:

- \`register@id.ai\` (case-insensitive) → Ok.
- \`recover@id.ai\` (case-insensitive) → Ok. Recognised at the
  validate query even on this PR (where the actual handler lives
  in the recovery follow-up #3843), since a "yes accept" here
  with no handler is harmless and avoids a deploy-step ordering
  constraint between this PR and the recovery one.
- Everything else → 550 (mailbox unavailable). Numeric postbox-
  style addresses are no longer handled; the gateway should
  bounce them.

The query is open (anyone can call it) but has no side effects
and leaks nothing beyond the two recipient labels themselves,
both already documented in the design doc.

Six new unit tests cover register / recover / case-insensitivity,
unknown user, known user with wrong domain, missing envelope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
The canister no longer returns a \`mailbox\` field on the prepare
response — all \`related_origins\` aliases are accepted equally on
the SMTP side, and the FE picks the user-facing label by pairing
the recipient user-part (\`register\` / \`recover\`) with
\`window.location.hostname\`. Each tab automatically shows the
alias matching the origin the user is on, and the canister never
has to single one out as canonical.

Generated TS bindings regenerated from the updated \`.did\` (the
\`EmailRecoveryChallenge\` type loses its \`mailbox\` field).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@sea-snake sea-snake force-pushed the feat/email-recovery-flow branch from 6cbfe79 to bcb5975 Compare May 13, 2026 11:25
@sea-snake sea-snake force-pushed the feat/email-recovery-frontend branch from db81b9f to 728bf87 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