Skip to content

feat(email-recovery): recovery flow — prepare_delegation + delegation stamping + get_delegation (PR 7 of email-recovery stack)#3843

Closed
sea-snake wants to merge 6 commits into
dfinity:feat/email-recovery-storage-and-smtpfrom
sea-snake:feat/email-recovery-flow
Closed

feat(email-recovery): recovery flow — prepare_delegation + delegation stamping + get_delegation (PR 7 of email-recovery stack)#3843
sea-snake wants to merge 6 commits into
dfinity:feat/email-recovery-storage-and-smtpfrom
sea-snake:feat/email-recovery-flow

Conversation

@sea-snake
Copy link
Copy Markdown
Contributor

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

Summary

Recovery flow on top of the two-phase DNSSEC architecture. Stacked on #3842 (setup flow).

  • email_recovery_prepare_delegation(dns_input, session_pk) — anonymous. Same as setup-prepare plus a FE-generated session public key that the eventual delegation will be bound to.
  • email_recovery_get_delegation(nonce, session_key, expiration) — query. After RecoveryReady, the FE fetches the SignedDelegation.
  • Reverse address → AnchorNumber stable index (memory ID 24) for resolving the verified From: to an anchor at recovery time.
  • RecoveryReady status variant carries anchor_number so the FE seeds its auth store directly.
  • Recovery delegation principals recognised by check_authorization via a new AuthorizationKey::EmailRecoveryAddress variant.

PR Stack

# PR Description Status
0 #3836 Design doc Open
1 #3838 DNSSEC verifier scaffold Open
2 #3839 DKIM verifier (RFC 6376) Open
3 #3840 DMARC alignment (RFC 7489) Open
4 #3841 DoH fallback Open
5+6 #3842 Setup flow (storage + smtp_request) Open
7 this PR Recovery flow (delegation) Open
8 #3844 Frontend + feature flag Open
9 #3855 Deploy/upgrade scripts: dnssec_config + doh_config Open
10 #3857 Email-recovery UX overhaul Open

Changes during review

  • RecoveryReady gained an anchor_number field. The recovery flow already knows the anchor at smtp time (address → anchor reverse index) — surfacing it on the status payload lets the FE seed its auth store directly instead of running a recovery-phrase-keyed lookup against an email-recovery delegation. (See PR feat(email-recovery): frontend wizard + EMAIL_RECOVERY flag (beta.id.ai default-on) #3844's discussion.)
  • bind_credential_to_anchor now refuses cross-anchor rebinds (EmailRecoveryError::AddressAlreadyRegistered); same-anchor rebinds remain idempotent.
  • Recovery delegation principals are now recognised by check_authorization: AuthorizationKey gains an EmailRecoveryAddress(String) variant and the authz check derives the canister-sig principal from H(salt || "email-recovery" || lowercase(address) || anchor) for each bound credential. activity_bookkeeping updates last_used on the matched credential, and the daily/monthly stats counter gets an email_recovery_counter.

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings May 6, 2026 09:02
@sea-snake sea-snake requested a review from a team as a code owner May 6, 2026 09:02
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds the recovery half of the email-recovery feature on top of the existing setup flow, enabling an anonymous “email proves control” path that issues an IC delegation after DKIM/DMARC verification, backed by a stable reverse index from email-hash → anchor.

Changes:

  • Adds anonymous recovery entry points (email_recovery_prepare_delegation, email_recovery_get_delegation) and delegation issuance/stamping after a verified recovery email arrives.
  • Extends smtp_request handling to safely route setup vs recovery challenges and perform verification + state transitions for both flows.
  • Adds/uses stable reverse lookup for resolving the anchor from the verified From: address, plus PocketIC integration coverage and fixtures.

Reviewed changes

Copilot reviewed 62 out of 63 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/internet_identity/src/email_recovery/prepare.rs Adds/uses shared prepare logic for setup + recovery, including selector validation and pending-challenge creation for recovery.
src/internet_identity/src/email_recovery/smtp.rs Verifies inbound emails, resolves anchor for recovery, and triggers delegation stamping/signature issuance.
src/internet_identity/src/email_recovery/remove.rs Removes bound recovery credential and returns an archive Operation for bookkeeping.
src/internet_identity/src/main.rs Exposes new canister methods for prepare_delegation/get_delegation and wires them into the module.
src/internet_identity_interface/src/internet_identity/types/email_recovery.rs Defines/extends the public Candid types and error surface for recovery.
src/internet_identity/tests/integration/email_recovery.rs Adds PocketIC end-to-end coverage for the full recovery flow and unbound-address failure.
test_vectors/dnssec/iana-root-anchors-2026-05.json Adds DNSSEC trust-anchor test fixture.
test_vectors/dkim/synth-rsa-test1._domainkey.test.example.com.txt Adds DKIM TXT fixture for verifier tests.
test_vectors/dkim/synth-rsa-*.eml Adds DKIM-signed email fixtures used by verifier tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/internet_identity/src/email_recovery/smtp.rs
Comment thread src/internet_identity/src/email_recovery/prepare.rs Outdated
Comment thread src/internet_identity/src/email_recovery/prepare.rs Outdated
Comment thread src/internet_identity/src/email_recovery/remove.rs Outdated
sea-snake and others added 6 commits May 11, 2026 16:00
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>
Two follow-ups from PR dfinity#3855 review on the reverse address index:

- `Storage::update_email_recovery_lookup` now returns
  `Result<(), AnchorNumber>` and rejects when the new address is
  already bound to a *different* anchor (Err carries the existing
  anchor). Same-anchor rebinds remain idempotent so a user retrying
  the wizard against their own anchor still works.
- A new `StorageError::EmailRecoveryAddressAlreadyBound { existing_anchor }`
  variant carries that conflict back through `Storage::write`.
  `email_recovery::smtp::bind_credential` matches it and surfaces
  the user-facing `EmailRecoveryError::AddressAlreadyRegistered`
  instead of the InternalCanisterError catch-all the previous
  `format!("write anchor: {e:?}")` produced.
- `StorableEmailRecoveryAddressHash::from_bytes` no longer panics
  on unexpected input. Switched to the same `slice_to_bounded_32`
  helper `StorableApplication`'s hash uses, so corrupted stable
  memory zero-pads / truncates to 32 instead of trapping mid-call.

444 unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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 the verifier
needs the DNSKEY chain for every zone touched and must authenticate
each hop independently against its own zone.

Bundle shape (interface + internal):

  DnsProofBundle {
      root_dnskey: SignedRRset,
      chains:      Vec<DelegationChain>,   // one per signing zone
      hops:        Vec<SignedRRset>,       // CNAME, …, final TXT
  }

Verification (verify.rs):

- verify_root_dnskey_with_clock — root vs. trust anchors + freshness
- verify_chain_with_clock(chain, root_dnskey) — walk one chain
- verify_extra_chains_with_clock — populate (zone → DNSKEY) map
- verify_hops_with_clock — per-hop signature under signer_name's zone,
  CNAME chain coherence (first owner == requested_name, intermediates
  are CNAMEs whose target equals next owner, final type matches,
  no loops, ≤ MAX_CNAME_HOPS = 4)
- verify_bundle_with_clock — top-level convenience

Cached pending-challenge state (pending.rs):

- cached_root_dnskey + cached_zones (ZoneKeysMap) replace the old
  single cached_zone_dnskey. The map starts with one zone (apex)
  for Gmail-style and grows at submit_dkim_leaf time when the DKIM
  CNAME chain crosses into a new signed zone.

submit_dkim_leaf API (interface + .did):

  EmailRecoverySubmitDkimLeafArg {
      nonce, hops, extra_chains
  }

The canister re-validates the cached root DNSKEY, walks any
extra_chains under it, validates each hop against the resulting
zone-keys map, then resolves the hop sequence to the final TXT for
DKIM verification.

Live.com case: apex signed but DKIM CNAMEs into unsigned territory.
The FE walker abandons on the first missing-RRSIG hop and falls
through to the DoH path — see scripts/default-doh-domains.bash on
the deploy-scripts branch.

Tests: 18 unit tests pass, including 5 new ones covering CNAME chain
coherence, duplicate-zone rejection, hop cap, owner mismatch, and
type mismatch.
The mod.rs preamble said the recovery half lives in a follow-up PR
(`feat/email-recovery-flow`, dfinity#3843). On the cumulative diff that
copilot reviews, that PR is part of the same stack and the recovery
half is right here in this module — so the docstring read as out of
date. Rewrite to describe both halves as living together.

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>
After the dnssec/* commits got absorbed into PR 1 (rebase took ours
for those conflicts), the only remaining PR 7 fixups are at the
canister-method boundary:

- internet_identity.did: EmailRecoverySubmitDkimLeafArg gained
  `hops: vec SignedRRset` + `extra_chains: vec DelegationChain`
  replacing the single `dkim_leaf: SignedRRset` field.
- submit_leaf.rs: use the multi-zone variant from PR 7 (was lost
  during rebase when 'take ours' replaced it with the
  storage-and-smtp single-leaf version).
- integration tests: update the EmailRecoverySubmitDkimLeafArg
  literal to the new shape.

463 unit tests pass; clippy clean.
@sea-snake sea-snake force-pushed the feat/email-recovery-flow branch from 2717fde to 52ed1eb Compare May 11, 2026 16:01
@sea-snake sea-snake changed the base branch from main to feat/email-recovery-storage-and-smtp May 12, 2026 11:49
@sea-snake
Copy link
Copy Markdown
Contributor Author

Replaced with a new PR from an upstream branch (enables direct collaboration). Same content, new PR number.

sea-snake added a commit that referenced this pull request May 12, 2026
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>
sea-snake added a commit that referenced this pull request May 12, 2026
The mod.rs preamble said the recovery half lives in a follow-up PR
(`feat/email-recovery-flow`, #3843). On the cumulative diff that
copilot reviews, that PR is part of the same stack and the recovery
half is right here in this module — so the docstring read as out of
date. Rewrite to describe both halves as living together.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
sea-snake added a commit that referenced this pull request May 12, 2026
…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>
sea-snake added a commit that referenced this pull request May 12, 2026
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>
sea-snake added a commit that referenced this pull request May 12, 2026
The mod.rs preamble said the recovery half lives in a follow-up PR
because it needs a reverse address index. That PR landed as the
flow branch (#3843) and is part of the same stack now, so the note
read as out-of-date in the cumulative diff. Trim it to point at
where the recovery half actually lives.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
sea-snake added a commit that referenced this pull request May 12, 2026
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>
sea-snake added a commit that referenced this pull request May 12, 2026
The mod.rs preamble said the recovery half lives in a follow-up PR
(`feat/email-recovery-flow`, #3843). On the cumulative diff that
copilot reviews, that PR is part of the same stack and the recovery
half is right here in this module — so the docstring read as out of
date. Rewrite to describe both halves as living together.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
sea-snake added a commit that referenced this pull request May 12, 2026
…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>
sea-snake added a commit that referenced this pull request May 12, 2026
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>
pull Bot pushed a commit to mikeyhodl/internet-identity that referenced this pull request May 12, 2026
…iring (dfinity#3838)

## Summary

First PR in the email-recovery stack (`docs/ongoing/email-recovery.md`
§10 Phase 0). Lands a working RFC-4035-compliant DNSSEC verifier for
caller-supplied DNS proof bundles, plus the trust-anchor wiring that
drives it. PR 2 (DKIM verifier) and PRs 4–9 (storage + recovery methods)
build on this.

## What's in this PR

### Verifier core
- New `dnssec/` module under `src/internet_identity/src/`:
- `types.rs` — `DnsProofBundle`, `SignedRRset`, `DelegationLink`,
`Rrsig`, `DnsName`, `DnssecError`, `VerifiedRecord`.
- `canonical.rs` — owner-name canonicalization, RR canonical form, RRSIG
signed-data construction (RFC 4034 §3.1.8.1, §6.2, §6.3), DS digest
input.
  - `signature.rs` — algorithm dispatch + DS digest matching (SHA-256).
- `verify.rs` — four-step algorithm (root anchor match → chain walk →
leaf RRSIG → freshness).
- Algorithm coverage (RFC 8624 MUST set):
  - **alg 8** — RSA-SHA256 (RFC 5702): root, com., most legacy zones.
- **alg 13** — ECDSA-P256-SHA256 (RFC 6605): most TLDs, Cloudflare,
modern zones.
  - **alg 15** — Ed25519 (RFC 8080): rare in production but mandatory.
  - Anything else returns `UnsupportedAlgorithm`.

### Wiring
- New `DnssecConfig` and `DnssecRootAnchor` types in
`internet_identity_interface`, exposed at the top of
`InternetIdentityInit` as `dnssec_config: opt opt DnssecConfig`
(set/clear semantics matching `analytics_config` and `dummy_auth`).
- Trust-anchor list plumbed through `init`/`post_upgrade` into
`PersistentState.dnssec_config` (and `StorablePersistentState` for
cross-upgrade persistence).
- `internet_identity.did` updated.

### Tests
13 unit tests in `dnssec/` covering:
- Real cloudflare.com chain verifies end-to-end (exercises alg 8 at root
and alg 13 at com → cloudflare.com → leaf).
- Empty trust-anchor list rejected with `NoTrustAnchors`.
- Wrong trust anchor rejected with `RootAnchorMismatch`.
- Flipped byte in root DNSKEY → `RootAnchorMismatch` or `BadSignature`.
- Flipped byte in leaf signature → `BadSignature`.
- Stale signature (clock advanced past expiration) →
`StaleOrFutureSignature`.
- Unsupported algorithm (alg 5 / RSA-SHA1) → `UnsupportedAlgorithm(5)`.
- Plus canonical-encoding + RFC 3110 RSA key parsing unit tests.

### Test infrastructure
- `test_vectors/dnssec/cloudflare-com-2026-05.json` — real DoH-captured
chain (root DNSKEY + 2 delegation links + leaf TXT).
- `test_vectors/dnssec/iana-root-anchors-2026-05.json` — IANA root KSK
trust anchors (Klajeyz/2017 + Kmyv6jo/2024).
- `scripts/capture-dnssec-chain.py` — reproducible capture script using
dnspython + DoH wire format. Tests use a frozen now from the capture's
metadata so freshness checks stay stable indefinitely.

### New deps
- `domain` (NLnet Labs, pure Rust) — referenced in docstrings for
canonicalisation primitives; signature verification is hand-rolled on
top of RustCrypto.
- `p256` — ECDSA P-256 verification.
- `ed25519-dalek` — Ed25519 verification.

All three build cleanly for wasm32-unknown-unknown.

## What's deferred to later PRs in the stack

- Captures for additional providers (proton.me, protonmail.com,
tutanota.com — gmail.com / icloud.com / outlook.com / fastmail.com don't
sign with DNSSEC; this is acknowledged in design doc §7.6).
- Synthetic Ed25519 (alg 15) test vector — most production zones are alg
8 or 13; alg 15 is structurally exercised by the dispatch logic but
doesn't have a real captured chain in this PR.

## Test plan

- [x] `cargo check -p internet_identity --target wasm32-unknown-unknown`
— clean (no warnings).
- [x] `cargo test -p internet_identity --bin internet_identity` — 238
tests pass (was 227 pre-PR).
- [x] `cargo test -p internet_identity_interface --lib` — 42 tests pass.
- [x] `cargo clippy -p internet_identity --bin internet_identity --tests
-- -D warnings` — clean.
- [x] `cargo fmt --check` — clean (modulo a pre-existing diff in
attributes.rs unrelated to this PR).
- [x] CI on dfinity#3838 — fully green.

## Design doc


https://github.com/sea-snake/internet-identity/blob/design/email-recovery/docs/ongoing/email-recovery.md
(PR pending review on dfinity/internet-identity)

## Stack

This is PR 1 of a 12-PR series. Subsequent PRs:
- **PR 2** — mail-auth-backed DKIM verifier, consuming DnsProofBundle
from this PR.
- **PR 3** — DMARC alignment.
- **PRs 4–9** — storage + Candid + behavior for email recovery.
- **PRs 10–12** — frontend (DoH walker, Manage UI, recovery wizard).

## PR Stack
| # | PR | Description | Status |
|---|---|---|---|
| 0 | [dfinity#3836](dfinity#3836) |
Design doc | Open |
| 1 | [dfinity#3838](dfinity#3838) |
DNSSEC verifier scaffold | Open |
| 2 | [dfinity#3839](dfinity#3839) |
DKIM verifier (RFC 6376) | Open |
| 3 | [dfinity#3840](dfinity#3840) |
DMARC alignment (RFC 7489) | Open |
| 4 | [dfinity#3841](dfinity#3841) |
DoH fallback | Open |
| 5+6 | [dfinity#3842](dfinity#3842)
| Setup flow (storage + smtp_request) | Open |
| 7 | [dfinity#3843](dfinity#3843) |
Recovery flow (delegation) | Open |
| 8 | [dfinity#3844](dfinity#3844) |
Frontend + feature flag | Open |
| 9 | [dfinity#3855](dfinity#3855) |
Deploy/upgrade scripts: dnssec_config + doh_config | Open |
| 10 | [dfinity#3857](dfinity#3857) |
Email-recovery UX overhaul | Open |
sea-snake added a commit that referenced this pull request May 13, 2026
The mod.rs preamble said the recovery half lives in a follow-up PR
because it needs a reverse address index. That PR landed as the
flow branch (#3843) and is part of the same stack now, so the note
read as out-of-date in the cumulative diff. Trim it to point at
where the recovery half actually lives.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
sea-snake added a commit that referenced this pull request May 13, 2026
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>
sea-snake added a commit that referenced this pull request May 13, 2026
The mod.rs preamble said the recovery half lives in a follow-up PR
(`feat/email-recovery-flow`, #3843). On the cumulative diff that
copilot reviews, that PR is part of the same stack and the recovery
half is right here in this module — so the docstring read as out of
date. Rewrite to describe both halves as living together.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
sea-snake added a commit that referenced this pull request May 13, 2026
…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>
sea-snake added a commit that referenced this pull request May 13, 2026
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>
sea-snake added a commit that referenced this pull request May 15, 2026
The mod.rs preamble said the recovery half lives in a follow-up PR
because it needs a reverse address index. That PR landed as the
flow branch (#3843) and is part of the same stack now, so the note
read as out-of-date in the cumulative diff. Trim it to point at
where the recovery half actually lives.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
sea-snake added a commit that referenced this pull request May 15, 2026
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>
sea-snake added a commit that referenced this pull request May 15, 2026
The mod.rs preamble said the recovery half lives in a follow-up PR
(`feat/email-recovery-flow`, #3843). On the cumulative diff that
copilot reviews, that PR is part of the same stack and the recovery
half is right here in this module — so the docstring read as out of
date. Rewrite to describe both halves as living together.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
sea-snake added a commit that referenced this pull request May 15, 2026
…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>
sea-snake added a commit that referenced this pull request May 15, 2026
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>
sea-snake added a commit that referenced this pull request May 15, 2026
The mod.rs preamble said the recovery half lives in a follow-up PR
because it needs a reverse address index. That PR landed as the
flow branch (#3843) and is part of the same stack now, so the note
read as out-of-date in the cumulative diff. Trim it to point at
where the recovery half actually lives.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
sea-snake added a commit that referenced this pull request May 15, 2026
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>
sea-snake added a commit that referenced this pull request May 15, 2026
The mod.rs preamble said the recovery half lives in a follow-up PR
(`feat/email-recovery-flow`, #3843). On the cumulative diff that
copilot reviews, that PR is part of the same stack and the recovery
half is right here in this module — so the docstring read as out of
date. Rewrite to describe both halves as living together.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
sea-snake added a commit that referenced this pull request May 15, 2026
…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>
sea-snake added a commit that referenced this pull request May 15, 2026
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>
sea-snake added a commit that referenced this pull request May 16, 2026
The mod.rs preamble said the recovery half lives in a follow-up PR
because it needs a reverse address index. That PR landed as the
flow branch (#3843) and is part of the same stack now, so the note
read as out-of-date in the cumulative diff. Trim it to point at
where the recovery half actually lives.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
sea-snake added a commit that referenced this pull request May 16, 2026
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>
sea-snake added a commit that referenced this pull request May 16, 2026
The mod.rs preamble said the recovery half lives in a follow-up PR
(`feat/email-recovery-flow`, #3843). On the cumulative diff that
copilot reviews, that PR is part of the same stack and the recovery
half is right here in this module — so the docstring read as out of
date. Rewrite to describe both halves as living together.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
sea-snake added a commit that referenced this pull request May 16, 2026
…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>
sea-snake added a commit that referenced this pull request May 16, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants