Skip to content

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

Closed
sea-snake wants to merge 21 commits into
dfinity:feat/email-recovery-flowfrom
sea-snake:feat/email-recovery-frontend
Closed

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

Conversation

@sea-snake
Copy link
Copy Markdown
Contributor

@sea-snake sea-snake commented May 6, 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 #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 #3843 Recovery flow (delegation) Open
8 this PR Frontend + feature flag Open
9 #3855 Deploy/upgrade scripts: dnssec_config + doh_config Open
10 #3857 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.

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

This PR (stacked on prior email-recovery PRs) wires up the frontend email-recovery setup + recovery wizards, adds the EMAIL_RECOVERY feature flag (default-off, auto-enabled on beta.id.ai), and includes supporting interface/backend changes so the UI can render email-recovery state and drive the canister recovery flow.

Changes:

  • Add Svelte wizards for setup (bind address + send magic email + polling) and recovery (anonymous flow producing a SignedDelegation).
  • Add a DNSSEC proof assembly client (wire-format DoH, parsing, chain walking) for caller-provided DNS proofs, with fallback behavior for unsigned domains.
  • Extend interface/backend types to surface email recovery credential state in IdentityInfo and support the end-to-end email recovery flows.

Reviewed changes

Copilot reviewed 90 out of 93 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
src/frontend/src/lib/state/featureFlags.ts Adds EMAIL_RECOVERY feature flag and environment-based override behavior.
src/frontend/src/routes/(new-styling)/manage/(authenticated)/(access-and-recovery)/recovery/+page.svelte Adds/manage-page wiring for the email-recovery card and setup entry point.
src/frontend/src/routes/(new-styling)/manage/(authenticated)/(access-and-recovery)/recovery/components/ActiveEmailRecovery.svelte Renders active/bound email-recovery credential details and actions.
src/frontend/src/routes/(new-styling)/manage/(authenticated)/(access-and-recovery)/recovery/components/InactiveEmailRecovery.svelte Renders inactive state and entry point for setting up email recovery.
src/frontend/src/routes/(new-styling)/recovery/+page.svelte Adds “Recover with email” entry point and session establishment from email-recovery delegation.
src/frontend/src/lib/components/wizards/setupEmailRecovery/index.ts Setup wizard exports and integration surface.
src/frontend/src/lib/components/wizards/setupEmailRecovery/SetupEmailRecoveryWizard.svelte Multi-step setup flow orchestration (prepare/add, polling, countdown UX).
src/frontend/src/lib/components/wizards/setupEmailRecovery/views/EnterAddress.svelte Collects user email address for setup flow.
src/frontend/src/lib/components/wizards/setupEmailRecovery/views/SendMagicEmail.svelte Displays “send email” step with countdown/polling UX.
src/frontend/src/lib/components/wizards/setupEmailRecovery/views/Done.svelte Setup completion view.
src/frontend/src/lib/components/wizards/setupEmailRecovery/views/FailedView.svelte Setup failure view and reason display.
src/frontend/src/lib/components/wizards/recoverWithEmail/index.ts Recovery wizard exports and integration surface.
src/frontend/src/lib/components/wizards/recoverWithEmail/RecoverWithEmailWizard.svelte Multi-step recovery flow orchestration (prepare/status/get delegation).
src/frontend/src/lib/components/wizards/recoverWithEmail/views/EnterAddressForRecovery.svelte Collects user email address for recovery flow.
src/frontend/src/lib/utils/dnssec/doh.ts Wire-format DoH client used for DNSSEC proof assembly.
src/frontend/src/lib/utils/dnssec/index.ts DNS message parsing + proof bundle construction utilities.
src/frontend/src/lib/utils/dnssec/rrsig.ts RRSIG parsing / key-tag computation utilities for DNSSEC assembly.
src/frontend/tests/e2e-playwright/routes/emailRecovery.spec.ts Playwright coverage for feature-flag gating and wizard mounting.
src/frontend/tests/e2e-playwright/fixtures/emailRecovery.ts E2E fixture helpers for email recovery flows.
src/frontend/tests/e2e-playwright/fixtures/index.ts Fixture exports wiring for Playwright tests.
src/frontend/src/routes/vc-flow/index/+page.svelte Adjustments related to routing/feature exposure in the frontend.
src/frontend/src/lib/utils/iiConnection.test.ts Test updates related to connection/util behavior affected by new flows.
src/internet_identity_interface/src/internet_identity/types/email_recovery.rs Interface types for email recovery flows and errors.
src/internet_identity_interface/src/internet_identity/types/api_v2.rs API surface updates to include email recovery in identity info / v2 types.
src/internet_identity_interface/src/internet_identity/types.rs Top-level type wiring changes for new modules/types.
src/internet_identity_interface/src/internet_identity/types/dnssec.rs Interface types for DNSSEC proof bundles and related structures.
src/internet_identity_interface/src/internet_identity/types/doh.rs Interface types/config for DoH fallback configuration.
src/internet_identity_interface/src/archive/types.rs Archive/interface adjustments due to expanded types.
src/internet_identity/internet_identity.did Candid interface updates for the new email recovery and related config/types.
src/archive/archive.did Archive candid updates aligned with interface/type changes.
src/internet_identity/Cargo.toml Backend dependencies/config updated for added verifier modules.
Cargo.toml Workspace-level dependency/config updates.
Cargo.lock Lockfile updates for new/updated dependencies.
src/internet_identity/src/email_recovery/mod.rs Email recovery module wiring.
src/internet_identity/src/email_recovery/prepare.rs Canister-side prepare logic for setup/recovery challenges.
src/internet_identity/src/email_recovery/pending.rs Pending challenge tracking/state for email recovery flows.
src/internet_identity/src/email_recovery/smtp.rs SMTP request handling and binding/recovery completion logic.
src/internet_identity/src/email_recovery/remove.rs Removal/unbind logic for email recovery credentials.
src/internet_identity/src/email_recovery/rng.rs RNG utilities for challenges/tokens.
src/internet_identity/src/dnssec/mod.rs DNSSEC verifier module wiring.
src/internet_identity/src/dnssec/types.rs DNSSEC proof/record types for verification.
src/internet_identity/src/dnssec/canonical.rs Canonical encoding primitives for DNSSEC verification.
src/internet_identity/src/dnssec/signature.rs DNSSEC signature verification primitives.
src/internet_identity/src/dnssec/verify.rs End-to-end DNSSEC chain + leaf verification logic.
src/internet_identity/src/dnssec/test_vectors.rs DNSSEC verification test vectors + harness.
src/internet_identity/src/dkim/mod.rs DKIM verifier module wiring.
src/internet_identity/src/dkim/types.rs DKIM verification result/error types.
src/internet_identity/src/dkim/parse.rs DKIM-Signature parsing.
src/internet_identity/src/dkim/canonicalize.rs DKIM header/body canonicalization.
src/internet_identity/src/dkim/dns_record.rs DKIM TXT record parsing.
src/internet_identity/src/dkim/signature.rs DKIM signature verification.
src/internet_identity/src/dkim/verify.rs DKIM orchestration/verification pipeline.
src/internet_identity/src/dkim/test_vectors.rs DKIM test vector harness.
src/internet_identity/src/dmarc/mod.rs DMARC verifier module wiring.
src/internet_identity/src/dmarc/types.rs DMARC types/outcomes/policy structures.
src/internet_identity/src/dmarc/from_header.rs From-header parsing for DMARC alignment.
src/internet_identity/src/dmarc/alignment.rs DMARC alignment evaluation logic.
src/internet_identity/src/dmarc/parse.rs DMARC TXT parsing.
src/internet_identity/src/dmarc/verify.rs DMARC orchestration (DKIM + From + alignment).
src/internet_identity/src/dmarc/test_vectors.rs DMARC test vector harness.
src/internet_identity/src/doh/types.rs DoH module types for config/errors.
src/internet_identity/src/storage.rs Storage wiring updated for email-recovery credential/index.
src/internet_identity/src/storage/storable.rs Storable types wiring for new persisted data.
src/internet_identity/src/storage/storable/anchor.rs Anchor schema update to include email recovery credential field.
src/internet_identity/src/storage/storable/email_recovery_credential.rs Persistent representation of bound email recovery credential.
src/internet_identity/src/storage/storable/email_recovery_address_hash.rs Address-hash type for reverse index (address → anchor).
src/internet_identity/src/storage/storable/storable_persistent_state.rs Persistent state updates for new configs/data.
src/internet_identity/src/storage/anchor.rs Anchor storage logic updated for email recovery fields.
src/internet_identity/src/storage/anchor/tests.rs Storage tests updated for new anchor/email-recovery behavior.
src/internet_identity/src/storage/tests.rs Storage-level tests updated for new structures/indices.
src/internet_identity/src/state.rs Persistent state wiring updates (dnssec/doh/email recovery config).
src/internet_identity/tests/integration/main.rs Integration test updates covering new canister behavior.
src/canister_tests/src/api/internet_identity.rs Canister-tests API updates for new endpoints/types.
src/canister_tests/src/api/archive.rs Canister-tests API updates for archive/type changes.
test_vectors/dnssec/iana-root-anchors-2026-05.json Root trust anchor test vector(s) for DNSSEC verification.
test_vectors/dkim/README.md DKIM test vector provenance/docs.
test_vectors/dkim/synth-rsa-test1._domainkey.test.example.com.txt DKIM TXT record test vector.
test_vectors/dkim/synth-rsa-simple-simple.eml DKIM signed email fixture (simple/simple).
test_vectors/dkim/synth-rsa-relaxed-simple.eml DKIM signed email fixture (relaxed/simple).
test_vectors/dkim/synth-rsa-relaxed-relaxed.eml DKIM signed email fixture (relaxed/relaxed).
scripts/capture-dnssec-chain.py Script to capture DNSSEC chains for reproducible test vectors.

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

Comment thread src/frontend/src/routes/(new-styling)/recovery/+page.svelte Outdated
Comment thread src/frontend/src/routes/(new-styling)/recovery/+page.svelte Outdated
Comment thread src/internet_identity/src/email_recovery/smtp.rs Outdated
Comment thread src/frontend/src/lib/utils/dnssec/doh.ts
Comment thread src/frontend/src/routes/(new-styling)/recovery/+page.svelte Outdated
@sea-snake sea-snake force-pushed the feat/email-recovery-frontend branch 10 times, most recently from 9e42d96 to abdcf07 Compare May 6, 2026 18:38
@sea-snake sea-snake force-pushed the feat/email-recovery-frontend branch 3 times, most recently from b8abb80 to 6f437cc Compare May 6, 2026 21:01
@sea-snake sea-snake force-pushed the feat/email-recovery-frontend branch from 4a596a2 to 0af14dd Compare May 11, 2026 15:29
sea-snake and others added 3 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>
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>
sea-snake and others added 14 commits May 11, 2026 16:00
`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>
`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 dfinity#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-frontend branch from 0af14dd to cfdaa12 Compare May 11, 2026 16:01
@sea-snake sea-snake changed the base branch from main to feat/email-recovery-flow 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 sea-snake closed this May 12, 2026
sea-snake added a commit that referenced this pull request May 12, 2026
…l args

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

What landed:

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

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

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

The reason we don't bundle the IANA anchors into the wasm is that
they roll over every ~7 years; the next rollover becomes a
one-line change in the next upgrade arg instead of a code change
+ recompile.
sea-snake added a commit that referenced this pull request May 12, 2026
…l args

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

What landed:

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

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

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

The reason we don't bundle the IANA anchors into the wasm is that
they roll over every ~7 years; the next rollover becomes a
one-line change in the next upgrade arg instead of a code change
+ recompile.
sea-snake added a commit that referenced this pull request May 12, 2026
…l args

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

What landed:

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

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

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

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

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

What landed:

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

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

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

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