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
Conversation
Contributor
There was a problem hiding this comment.
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_requesthandling 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.
This was referenced May 6, 2026
Closed
57808fc to
3477230
Compare
9 tasks
295fda8 to
94badc9
Compare
2 tasks
6788020 to
2717fde
Compare
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.
2717fde to
52ed1eb
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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. AfterRecoveryReady, the FE fetches theSignedDelegation.address → AnchorNumberstable index (memory ID 24) for resolving the verifiedFrom:to an anchor at recovery time.RecoveryReadystatus variant carriesanchor_numberso the FE seeds its auth store directly.check_authorizationvia a newAuthorizationKey::EmailRecoveryAddressvariant.PR Stack
Changes during review
RecoveryReadygained ananchor_numberfield. 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_anchornow refuses cross-anchor rebinds (EmailRecoveryError::AddressAlreadyRegistered); same-anchor rebinds remain idempotent.check_authorization:AuthorizationKeygains anEmailRecoveryAddress(String)variant and the authz check derives the canister-sig principal fromH(salt || "email-recovery" || lowercase(address) || anchor)for each bound credential.activity_bookkeepingupdateslast_usedon the matched credential, and the daily/monthly stats counter gets anemail_recovery_counter.🤖 Generated with Claude Code