Skip to content

feat(email-recovery): storage + smtp_request setup flow#3842

Closed
sea-snake wants to merge 23 commits into
dfinity:feat/doh-fallbackfrom
sea-snake:feat/email-recovery-storage-and-smtp
Closed

feat(email-recovery): storage + smtp_request setup flow#3842
sea-snake wants to merge 23 commits into
dfinity:feat/doh-fallbackfrom
sea-snake:feat/email-recovery-storage-and-smtp

Conversation

@sea-snake
Copy link
Copy Markdown
Contributor

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

Summary

PR 5+6 of the email-recovery stack — the storage layer plus the canister-side surface for the setup (binding) flow. Stacks on #3841 (DoH fallback).

The recovery half (prepare_delegation, recover@id.ai dispatch, delegation issuance) is deferred to a follow-up PR — it needs a stable reverse address → AnchorNumber index for the verified-From: lookup, which is best landed as its own focused change.

What this PR adds

Storage:

  • EmailRecoveryCredential lives directly on the anchor struct as Option<EmailRecoveryCredential> via a new #[n(5)] field on StorableAnchor. minicbor is forward-compatible across optional-field additions, so old anchors deserialize cleanly with None — no schema migration.
  • Heap-only pending-challenge map (10k capacity, 30-min TTL, oldest-first eviction). Heap rather than stable: entries expire in 30 minutes and losing them on canister upgrade just costs an in-flight wizard one retry, not worth the stable-memory churn.
  • ChaCha20Rng PRNG, seeded once per canister lifetime via raw_rand. Per-call cost is one PRNG draw, not a management-canister round trip.

Canister surface (Candid):

  • email_recovery_credential_prepare_add(IdentityNumber, EmailRecoveryDnsInput) -> Result<EmailRecoveryChallenge, EmailRecoveryError> — authenticated update.
  • email_recovery_status(text) -> EmailRecoveryStatus — anonymous query (FE polls).
  • email_recovery_credential_remove(IdentityNumber, text) -> Result<(), EmailRecoveryError> — authenticated update.
  • smtp_request(SmtpRequest) -> SmtpResponse — open update; the gateway entry point.

Two verification paths (FE is path-agnostic):

The FE just submits { address, selector, dns_proof: Option<DnsProofBundle> }. The canister picks the path:

Path When DKIM key sourced from Outcall at email time?
DNSSEC dns_proof is Some Validated at prepare time → cached on pending challenge No
DoH allowlist dns_proof is None crate::doh::fetch_txt (3-of-5 quorum, heap-cached) Yes (first email per domain per TTL)
Neither No proof + domain not allowlisted Rejects with DomainNotSupported / DomainNotAllowlisted

The DNSSEC path validates the chain synchronously against DnssecConfig.root_anchors and confirms the bundle's leaf is at <selector>._domainkey.<domain>. Candid-friendly mirror types (DnsProofBundle, SignedRRset, Rrsig, DelegationLink) are defined in the interface crate with From<> conversions to the canister-internal verifier types.

Verification pipeline (smtp_request):

  • Recipient dispatch on the full address (register@id.ai) — defence-in-depth against direct callers spoofing just the user-part.
  • Pulls the canister-issued nonce from the Subject: header (case-insensitive prefix match, hex-suffix length-checked).
  • Sources the DKIM key (cached DNSSEC TXT or DoH fetch).
  • Optionally fetches _dmarc.<domain>; absence forces strict alignment in the verifier.
  • Runs crate::dmarc::verify_email end-to-end (full DKIM + DMARC + Subject in h=).
  • On success: writes the credential to the anchor and flips the pending challenge to Succeeded. Failures flip to Failed(reason). The gateway always sees Ok.

What this PR does NOT add (intentional, deferred to follow-ups)

  • Recovery flowprepare_delegation, recover@id.ai dispatch, delegation issuance. Needs a reverse address index in stable storage; cleaner as its own PR.
  • Frontend — the wizard UX from design-doc §8.6 is its own stack of PRs.

Test plan

  • 441 unit tests pass: PRNG, pending-challenge map (TTL, eviction, status flips), prepare_add (input validation, allowlist gate, domain rejection, happy path), remove (happy path, case-insensitive match, idempotency edge cases), smtp.rs nonce extraction.
  • 12 PocketIC integration tests:
    • Nonce shape, allowlist gate, authz, status lifecycle (Pending → Expired), TTL expiry, silent-drop paths (no nonce, unknown nonce, wrong recipient).
    • DNSSEC path: no-trust-anchors rejection; DNSSEC-takes-precedence-over-DoH-allowlist.
    • Full DoH e2e: prepare → in-test DKIM signer → smtp_request → mocked DoH outcalls → RegistrationSucceeded → credential_remove succeeds.
  • cargo check --target wasm32-unknown-unknown clean.
  • cargo clippy --tests -- -D warnings clean.
  • cargo fmt --check clean.
  • .did interface stays in sync (the existing check_candid_interface_compatibility test enforces this).
  • Happy-path DNSSEC e2e test (with a real _domainkey chain).
  • Smoke test on local replica — deferred to FE wizard integration.

🤖 Generated with Claude Code

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 #3844 Frontend + feature flag Open
9 #3855 Deploy/upgrade scripts: dnssec_config + doh_config Open
10 #3857 Email-recovery UX overhaul Open

Changes during review

  • The on-anchor email_recovery field is now a Vec (Option<Vec<...>> on the storable form, matching the existing passkey_credentials/recovery_keys pattern). The canister API still enforces ≤1 entry — this is a data-model widening so future multi-credential support doesn't need another schema bump. Option<Vec<T>> means anchors written under the previous schema decode cleanly with email_recovery: None (verified empirically against the in-tree minicbor-derive version).

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

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds the storage and canister API surface for the email recovery setup (binding) flow, including heap-resident pending challenges, PRNG seeding, SMTP gateway entry point, and persistence of DNS verification configuration needed by the DKIM/DMARC pipeline.

Changes:

  • Persist email-recovery credential on anchors (stable) and add heap-only pending-challenge tracking + nonce generation.
  • Expose new Candid endpoints for email-recovery setup (prepare_add, status, remove) plus smtp_request.
  • Introduce / wire supporting DKIM+DMARC(+DNSSEC/DoH config) modules and test vectors used by the verification pipeline.

Reviewed changes

Copilot reviewed 58 out of 59 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
test_vectors/dnssec/iana-root-anchors-2026-05.json Adds root-anchor fixture for DNSSEC test vectors.
test_vectors/dkim/synth-rsa-test1._domainkey.test.example.com.txt Adds synthetic DKIM TXT fixture for verifier tests.
test_vectors/dkim/synth-rsa-simple-simple.eml Adds synthetic DKIM email fixture (unsupported canon case).
test_vectors/dkim/synth-rsa-relaxed-simple.eml Adds synthetic DKIM email fixture (relaxed/simple).
test_vectors/dkim/synth-rsa-relaxed-relaxed.eml Adds synthetic DKIM email fixture (relaxed/relaxed).
test_vectors/dkim/README.md Documents DKIM test vector provenance/regeneration.
src/internet_identity/src/storage/tests.rs Updates storage migration tests for new anchor field.
src/internet_identity/src/storage/storable/storable_persistent_state.rs Persists dnssec_config/doh_config in stable state.
src/internet_identity/src/storage/storable/email_recovery_credential.rs Adds stable representation for email recovery credential.
src/internet_identity/src/storage/storable/anchor.rs Adds email_recovery field to stable anchor encoding.
src/internet_identity/src/storage/storable.rs Exports new storable module.
src/internet_identity/src/storage/anchor/tests.rs Updates anchor invariants tests for new field.
src/internet_identity/src/storage/anchor.rs Adds in-memory anchor field + conversions to/from storable form.
src/internet_identity/src/storage.rs Ensures anchor write path handles new field (no index sync needed).
src/internet_identity/src/state.rs Adds dnssec_config/doh_config to persistent state defaults.
src/internet_identity/src/main.rs Wires configs into config()/install arg, adds email-recovery canister methods.
src/internet_identity/src/email_recovery/mod.rs New module for setup flow orchestration + constants.
src/internet_identity/src/email_recovery/pending.rs Heap-only pending challenge map with TTL + eviction policy.
src/internet_identity/src/email_recovery/rng.rs Heap PRNG seeded once via raw_rand for nonce generation.
src/internet_identity/src/email_recovery/prepare.rs Implements prepare_add: validation, allowlist gate, nonce issuance.
src/internet_identity/src/email_recovery/remove.rs Implements credential removal logic + archive operation placeholder.
src/internet_identity/src/email_recovery/smtp.rs Implements smtp_request dispatch + end-to-end verification/binding.
src/internet_identity/src/doh/types.rs Internal DoH provider/quorum configuration types.
src/internet_identity/src/dnssec/mod.rs DNSSEC verifier module scaffold + exports.
src/internet_identity/src/dnssec/types.rs DNSSEC proof-bundle internal types.
src/internet_identity/src/dnssec/canonical.rs DNSSEC canonicalization helpers for signature verification.
src/internet_identity/src/dnssec/signature.rs DNSSEC signature verification + DS digest matching.
src/internet_identity/src/dnssec/test_vectors.rs Test-vector loader for DNSSEC chain/anchor fixtures.
src/internet_identity/src/dmarc/mod.rs DMARC module scaffold + exports.
src/internet_identity/src/dmarc/types.rs DMARC types + combined DKIM/DMARC verification status type.
src/internet_identity/src/dmarc/parse.rs DMARC TXT parser.
src/internet_identity/src/dmarc/from_header.rs Minimal From-header mailbox/domain extractor for DMARC.
src/internet_identity/src/dmarc/alignment.rs Strict/relaxed domain alignment check.
src/internet_identity/src/dmarc/verify.rs Orchestrates DKIM verification + DMARC alignment decision.
src/internet_identity/src/dmarc/test_vectors.rs End-to-end DKIM+DMARC tests using committed fixtures.
src/internet_identity/src/dkim/mod.rs DKIM verifier module scaffold + exports.
src/internet_identity/src/dkim/types.rs DKIM verifier result/check types and failure reasons.
src/internet_identity/src/dkim/parse.rs (Implicit in module) DKIM-Signature parsing support.
src/internet_identity/src/dkim/canonicalize.rs DKIM relaxed canonicalization implementation.
src/internet_identity/src/dkim/dns_record.rs DKIM TXT record parser.
src/internet_identity/src/dkim/signature.rs DKIM cryptographic signature verification (RSA/Ed25519).
src/internet_identity/src/dkim/test_vectors.rs DKIM fixture loader + end-to-end DKIM verification tests.
src/internet_identity/internet_identity.did Extends Candid with DNSSEC/DoH config and email-recovery/smtp APIs.
src/internet_identity/Cargo.toml Adds deps needed by DNSSEC/DoH/email pipeline.
src/internet_identity_interface/src/internet_identity/types/smtp.rs Adds SMTP gateway wire types + validation helpers.
src/internet_identity_interface/src/internet_identity/types/email_recovery.rs Adds email recovery wire types/errors/statuses.
src/internet_identity_interface/src/internet_identity/types/doh.rs Adds DoH config type to the interface crate.
src/internet_identity_interface/src/internet_identity/types/dnssec.rs Adds DNSSEC config/anchor types to the interface crate.
src/internet_identity_interface/src/internet_identity/types.rs Re-exports new interface types and extends init arg struct.
scripts/capture-dnssec-chain.py Adds utility to capture DNSSEC chains into test-vector JSON.
Cargo.toml Adds workspace deps (p256, ed25519-dalek).
Cargo.lock Lockfile updates for new dependencies.

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

Comment thread src/internet_identity/src/email_recovery/prepare.rs
Comment thread src/internet_identity/src/email_recovery/prepare.rs Outdated
Comment thread src/internet_identity/src/email_recovery/smtp.rs
Comment thread src/internet_identity/src/email_recovery/smtp.rs Outdated
Comment thread src/internet_identity_interface/src/internet_identity/types/email_recovery.rs Outdated
Comment thread src/internet_identity_interface/src/internet_identity/types/email_recovery.rs Outdated
Comment thread src/internet_identity/src/storage/storable/email_recovery_credential.rs Outdated
Comment thread src/internet_identity/src/email_recovery/remove.rs Outdated
sea-snake and others added 16 commits May 11, 2026 16:00
Lays the storage foundation for the email-recovery flow (PRs 5+6 of
the email-recovery stack — see docs/ongoing/email-recovery.md):

- New module `internet_identity_interface::email_recovery` with the
  Candid types: EmailRecoveryCredential, EmailRecoveryChallenge,
  EmailRecoveryDnsInput (DohAllowlist + Dnssec stub), EmailRecoveryError,
  EmailRecoveryStatus, EmailRecoveryGetDelegationArgs.

- New `StorableEmailRecoveryCredential` mirrors the on-disk shape via
  `minicbor-derive`. Lives inline on `StorableAnchor` as `#[n(5)]
  email_recovery: Option<...>`. minicbor's `#[cbor(map)]` is
  forward-compatible across optional-field additions, so old anchors
  decoded under the previous schema deserialize cleanly with `None`.

- `Anchor` (in-memory) gains `email_recovery: Option<EmailRecoveryCredential>`,
  threaded through the three Storable<->Anchor conversion sites and the
  storage write-merge in `Storage::write_anchor` (the new field needs
  no merge — it's an overwrite-on-write single-slot credential).

This commit only adds the data structure. Behaviour (canister methods,
nonce PRNG, reverse address index, pending-challenge map, smtp_request)
lands in subsequent commits on this branch.

414 unit tests pass; wasm32 build clean.
Continues the email-recovery setup flow (PRs 5+6 of the email-recovery
stack). Adds three building blocks under a new `email_recovery` module:

`email_recovery/rng.rs`
- A heap-resident ChaCha20Rng PRNG, seeded once per canister lifetime
  via `random_salt()` (which calls `raw_rand` under the hood). The
  first prepare call after canister start triggers the async fetch;
  every subsequent draw is a single PRNG step.
- `format_nonce` produces `II-Recovery-{16-hex-chars}` — the verbose
  prefix is deliberate (every legitimate UI surface starts with it,
  so a phisher's alternative format is an obvious tell — see design
  §3.2).

`email_recovery/pending.rs`
- Heap-only `HashMap<String, PendingChallenge>` with 30-min TTL and
  oldest-first eviction at the bounded capacity (10 000).
- Heap rather than stable: pending entries expire in 30 minutes and
  losing them on canister upgrade just costs an in-flight wizard one
  retry, not worth the stable-memory churn.
- Random-nonce-only key gives the §8.8 untargetability property —
  attackers can fill the map but can't pick whose entry gets evicted.
- Lazy TTL eviction on `status_of` reads + sweep on `insert`, so
  polling sees `Expired` at the right moment without paying O(n) on
  every read.

`email_recovery/prepare.rs`
- `prepare_add(anchor, dns_input, now_secs)` — async, validates
  input, draws a fresh nonce, parks a `Pending` challenge.
- Variant dispatch: only `DohAllowlist` is wired up;
  `EmailRecoveryDnsInput::Dnssec` returns
  `DnssecPathNotYetSupported`. The DNSSEC path lands in a follow-up.
- DoH allowlist gate happens up front so a non-supported domain
  fails at prepare with a clear error, not after the user has
  composed an email.
- Address normalisation is intentionally minimal: lowercase + reject
  obvious-garbage shapes. Strict RFC 5322 validation lives in the
  verifier (PRs 2-3).
- Nonce-collision retry loop guards against the vanishingly small
  birthday-collision case (8 attempts, then bail).

17 new unit tests (rng + pending + prepare); full unit suite green.
The `prepare_add` canister method is wired up in a subsequent commit
on this branch.
…methods

Adds the canister-method surface for the setup-half of email recovery:

- `email_recovery_credential_prepare_add(identity_number, dns_input)`
  — authenticated update. Validates that the caller owns the anchor,
  delegates to `email_recovery::prepare_add` for input validation +
  nonce issuance, returns the user-visible `EmailRecoveryChallenge`.
- `email_recovery_status(nonce)` — anonymous query the FE polls
  while the user composes the email. Returns `Pending` →
  `RegistrationSucceeded` / `Failed` / `Expired`.
- `email_recovery_credential_remove(identity_number, address)` —
  authenticated update. Detaches the recovery email from the anchor
  via the new `email_recovery::remove_credential` helper. Inlined
  authz (rather than using `anchor_operation_with_authz_check`) to
  avoid an awkward orphan-rule From<IdentityUpdateError> impl on an
  interface-crate error type.

`.did` file: adds `EmailRecoveryCredential`, `EmailRecoveryChallenge`,
`EmailRecoveryDnsInput`, `EmailRecoveryError`, `EmailRecoveryStatus`,
`DohAllowlistDnsInput` types and the three new methods.

`email_recovery::remove_credential` lives in a new `remove.rs`
submodule with five unit tests covering the happy path, case-
insensitive match, unbound anchor, wrong address (no partial
mutation), and double-remove idempotency.

`pending_status` is exposed as a thin `mod.rs` wrapper so `main.rs`
doesn't need to know which submodule the heap state lives in.

The `smtp_request` path (where the gateway forwards an inbound email
and the canister verifies + binds) is the next commit on this branch.

436 unit tests pass (up from 414); wasm32 build clean; .did matches.
Two related changes:

1. **smtp_request handler.** New `email_recovery::smtp` submodule
   that accepts an inbound email from the gateway and runs the full
   verification pipeline:
     - Bound-checks the SmtpRequest payload.
     - Dispatches by recipient: `register@id.ai` for setup; other
       recipients are silently dropped (no per-recipient signal back
       to the gateway, so it can't probe the canister for which
       mailboxes exist).
     - Extracts the canister-issued nonce from the `Subject:` header
       (case-insensitive prefix match, hex-suffix length-checked).
     - Looks up the pending challenge, snapshotting it under a brief
       borrow so the async DoH fetches don't hold a `RefCell` across
       awaits.
     - Resolves the DKIM key via `crate::doh::fetch_txt(<selector>.
       _domainkey.<domain>, domain)` — heap-cached after the first
       lookup per provider per TTL window.
     - Optionally fetches `_dmarc.<domain>`; absence forces strict
       alignment in the verifier.
     - Runs `crate::dmarc::verify_email` end-to-end (full DKIM +
       DMARC check + the `Subject in h=` enforcement added during
       the PR 5 review).
     - On success, writes the credential to the anchor and flips
       the pending challenge to `Succeeded`. Failures flip it to
       `Failed(reason)` for the FE poll. The gateway always sees
       `SmtpResponse::Ok`.
   Wired up as `smtp_request` in main.rs's email_recovery_api
   submodule and added to the .did.

2. **Path-agnostic prepare API.** Per review feedback ("the FE
   shouldn't need to know about the DoH allowlist"), drop the
   `EmailRecoveryDnsInput::DohAllowlist | Dnssec` variant and
   replace it with a flat `record { address; selector }`. The FE
   just submits these; the canister picks the path:
     - Future DNSSEC follow-up adds an optional `dns_proof` field
       (Candid-forward-compatible). When supplied, the canister
       validates the chain. When absent, falls through to the DoH
       allowlist check.
     - Currently only the DoH branch is wired up; non-allowlisted
       domains return `DomainNotSupported` (renamed from the now-
       removed `DomainNotAllowlisted`).
   Drops the `DnssecPathNotYetSupported` error variant — the FE
   never sees a DNSSEC-specific rejection, so there's no need to
   carry the variant.

`.did` updates: `EmailRecoveryDnsInput` is now a record, the SMTP
gateway types (SmtpRequest/SmtpResponse and friends) are declared,
and the `smtp_request` method is exposed.

441 unit tests pass (up from 436, +5 smtp parser/extract tests);
wasm32 build clean; clippy `-D warnings` clean.
Adds an integration-test module exercising the canister-method
surface end-to-end via PocketIC. Covers:

- Nonce shape from `email_recovery_credential_prepare_add` (prefix
  + length + mailbox + expires_at).
- Allowlist gate (non-allowlisted domain → `DomainNotSupported`).
- Authorization (a non-owner principal cannot prepare for an anchor).
- Status query semantics: `Expired` for unknown nonces, `Pending`
  after prepare_add, `Expired` after TTL via `env.advance_time`.
- `smtp_request` silent-drop paths: missing nonce in Subject,
  unknown nonce, non-`register@` recipient.
- `email_recovery_credential_remove` rejection when nothing bound.
- **Full end-to-end binding flow**: prepare_add → in-test DKIM
  signer → `smtp_request` → mocked DoH outcalls → status flips to
  `RegistrationSucceeded` → `credential_remove` succeeds (proves the
  binding actually persisted to the anchor).

The DKIM signer is a small in-test module (~200 LOC) that mirrors
the verifier's relaxed canonicalization rules, generates a fresh
2048-bit RSA keypair per test, and signs against
`<selector>._domainkey.<domain>`. The matching `v=DKIM1; k=rsa; p=…`
TXT record is wrapped in a wire-format DNS response and served back
via `env.mock_canister_http_response` for each of the 5 DoH provider
outcalls. DMARC outcalls are answered with 404 (no record), which
forces strict alignment — and our test setup satisfies that
(DKIM `d=` equals From: domain).

Adds matching helpers to `canister_tests::api::internet_identity`:
- `email_recovery_credential_prepare_add`
- `email_recovery_status`
- `email_recovery_credential_remove`
- `smtp_request`

The integration test crate's existing build setup (II_WASM env var,
PocketIC binary on PATH) is reused unchanged.
…lure

CI failure (`expected RegistrationSucceeded, got Failed(...DnsRecordMalformed("RSA SPKI decode...")...)`):
- The in-test DKIM signer was encoding the public key as PKCS#1
  RsaPublicKey, but the canister verifier expects an X.509
  SubjectPublicKeyInfo (SPKI) per RFC 5280 §4.1 — which is also what
  real DKIM records publish in the `p=` tag. Switch to
  `to_public_key_der()`.

Copilot review comments addressed:

- `EmailRecoveryChallenge.expires_at` now in **nanoseconds** since
  epoch (matches `Timestamp` semantics — was off by a factor of 1e9).
- Non-allowlisted domain at prepare time now returns
  `DomainNotAllowlisted(domain)` instead of `DomainNotSupported`.
  `DomainNotSupported` is reserved for genuinely unsupported domain
  shapes (and for the future DNSSEC-attempted-but-failed case).
- `smtp_request` recipient dispatch now matches on the **full**
  recipient address (user + domain), case-insensitive on both
  halves. New `SETUP_RECIPIENT_DOMAIN = "id.ai"` constant. A direct
  caller can no longer set `to.user="register"` with an arbitrary
  domain to bypass dispatch.
- `smtp_request` now maps `DohError` variants to typed
  `EmailRecoveryError`s: `NotConfigured` / `DomainNotAllowed` →
  `DomainNotAllowlisted` (so the FE shows "operator hasn't enabled
  this domain"); `AllProvidersFailed` / `QuorumFailed` /
  `ResponseMalformed` → `DohFetchFailed` (transient-error UX);
  `InvalidName` / `NameOutsideRegisteredDomain` →
  `InternalCanisterError` (caller-bug variants `prepare_add` should
  have caught).
- Doc fixes:
  - Interface module-level: `5-of-3 quorum` → `3-of-5 quorum`.
  - `EmailRecoveryCredential` and `StorableEmailRecoveryCredential`:
    `created_at` / `last_used` units corrected from "Unix-seconds"
    to "nanoseconds since epoch".
  - `EmailRecoveryChallenge.nonce` doc: "~16 base32 characters" →
    "16 lowercase hex characters (8 random bytes)".
  - `EmailRecoveryChallenge.expires_at` doc: "Unix seconds" →
    "nanoseconds since epoch".
  - `email_recovery::remove`: doc no longer claims to be wrapped in
    `anchor_operation_with_authz_check` — that helper isn't used at
    the canister-method layer (orphan-rule constraint on the
    interface-crate error type, see `main.rs::email_recovery_api`).

Tests updated for the new units; 441 unit tests still pass.
Adds the DNSSEC verification path to the setup flow. The FE can now
optionally pass a `DnsProofBundle` alongside the address+selector;
when supplied, the canister:

1. Validates the chain synchronously against its configured
   `DnssecConfig.root_anchors` at prepare time.
2. Confirms the bundle's leaf is a TXT record at the expected
   `<selector>._domainkey.<registered_domain>` name (otherwise an
   attacker who got a chain validated for *some* TXT record could
   bind that record's content as a DKIM key).
3. Caches the verified TXT bytes on the pending challenge.

`smtp_request` then uses the cached TXT directly — no DoH outcall
fan-out at email-arrival time. When `dns_proof` is absent, falls
through to the existing DoH allowlist path unchanged.

Wire-level changes:

- `internet_identity_interface::types::dnssec`: adds Candid-friendly
  mirror types for `DnsProofBundle`, `SignedRRset`, `Rrsig`,
  `DelegationLink`. Wire-format owner names are `blob`s; the
  canister-side verifier types stay as-is and are bridged via
  `From<>` impls in `dnssec::types::interface_conversions`.
- `EmailRecoveryDnsInput` gains an optional `dns_proof:
  Option<DnsProofBundle>` field. Forward-compatible Candid extension.
- `.did` declares the new types.
- `dnssec::types`: adds `TYPE_TXT` / `TYPE_DS` / `TYPE_DNSKEY`
  constants and re-exports them from `dnssec::mod`.
- `dnssec::mod`: makes the `types` submodule `pub(crate)` so the
  email-recovery module can reach the internal `DnsProofBundle`
  via `From` conversions.
- `PendingChallenge` gains `cached_dkim_txt: Option<Vec<u8>>` so
  `smtp_request` knows whether to skip the DoH path.

Two new PocketIC integration tests cover the plumbing end-to-end:

- `dnssec_path_rejects_when_no_trust_anchors_configured` — supplies
  a structurally-valid bundle without anchor config; expects
  `DomainNotSupported("…trust anchors not configured…")`.
- `dnssec_path_takes_precedence_over_doh_allowlist` — supplies a
  malformed bundle for a domain that *is* on the DoH allowlist;
  expects DNSSEC failure rather than fall-through to DoH.

A happy-path DNSSEC test (with a real `<selector>._domainkey.<domain>`
chain) is a follow-up — generating a DNSSEC chain at test time would
be a multi-hundred-line in-test signer; the existing
`crate::dnssec::test_vectors` cloudflare.com fixture's leaf is a
plain `cloudflare.com` TXT, not a `_domainkey` TXT.

Also addresses the earlier Copilot review feedback on PR dfinity#3842 (in a
separate commit on this branch): expires_at units, error variant
mapping, recipient-dispatch full-address match, DoH error mapping.
…ted split

The Copilot-feedback fix introduced a dedicated `DomainNotAllowlisted`
variant for the DoH allowlist-miss case (previously collapsed into
`DomainNotSupported`), but missed updating this integration test.

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

The DNSSEC path used to short-circuit only the DKIM DoH outcall — DMARC
still fanned out at email-arrival time, defeating the goal of "DNSSEC
on, DoH off." Generalise `DnsProofBundle` to carry one *or more* leaves
under the same chain walk; `prepare_add` extracts DKIM (required) and
DMARC (optional) by name, caches both on the pending challenge, and
`smtp_request` skips the DoH fan-out entirely on this path.

Also fix `status_flips_to_expired_after_ttl`: PocketIC's query path
doesn't see `advance_time` without a `tick()` to roll the round over.

Adds `full_setup_flow_via_dnssec_path` — the missing happy-path
integration test, with an in-test Ed25519 DNSSEC signer that builds a
fresh root → leaf-zone chain and asserts zero DoH outcalls observed.

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

The root terminator (`\x00`) was treated as an extra empty label and
got its own '.' appended, on top of the trailing dot already added
after the final real label. So `\x05test1\x0a_domainkey…\x00` decoded
to `test1._domainkey.…com..` — never equal to the FQDN we compare it
to. Existing tests masked this: the only callers reached this branch
on the trust-anchors-empty short-circuit, never the name compare.

Surfaced now by full_setup_flow_via_dnssec_path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…are equal e2e flows

The module-level docstring presented DoH-with-mocked-outcalls as the
canonical e2e setup, and the DoH happy-path test was titled "Full
end-to-end test" with a comment that read "this one does the real
work" — implicitly demoting the DNSSEC happy-path test to second-tier.
Both are full e2e flows; reframe the docs to make that explicit and
add parallel section headers ("DoH path: full end-to-end setup flow"
vs "DNSSEC path: full end-to-end setup flow + protocol-shape checks").

Comment-only change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`normalize_address` accepted any non-empty, no-whitespace, has-`@`
string. With the 2 MiB ingress limit a caller could submit a multi-KB
address and inflate the heap-resident pending-challenge map.

Apply the standard caps at every address-handling boundary:
- `MAX_ADDRESS = 254` (path limit minus the `<>` framing — RFC 5321
  §4.5.3.1.3 with the corrected interpretation, errata to RFC 3696).
- `MAX_LOCAL_PART = 64` (§4.5.3.1.1).
- `MAX_DOMAIN = 255` (§4.5.3.1.2).

Enforced at prepare time (so an invalid address never gets a nonce)
and at `smtp_request` From-extraction time (defense in depth — the
gateway shouldn't pass through oversized addresses but we don't rely
on it).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Several module-level and inline comments still claimed DoH was the
only path, that DNSSEC was deferred, that callers hadn't landed, or
referenced specific PR numbers in the now-collapsed stack. Now that
PR dfinity#3842 landed both paths, the on-anchor credential, and the DKIM /
DMARC / DoH / DNSSEC consumers, the comments needed to catch up.

Concrete updates:
- main.rs: prepare_add doc no longer says "DoH path only in this PR";
  describes the actual path picker.
- email_recovery.rs interface module + EmailRecoveryDnsInput doc:
  drops "DNSSEC deferred to a follow-up PR" / "DoH only in initial
  cut" framing; now describes both paths as live with the canister
  picking per call.
- dkim/mod.rs, dmarc/mod.rs, dnssec/mod.rs: dead-code-allow rationale
  rewritten to point at the actual in-canister consumer
  (`crate::email_recovery::smtp::verify_setup_email`) instead of
  promising future PRs.
- dkim/verify.rs, dmarc/verify.rs: data-flow doc no longer lists
  inputs as coming from "PR 1 / PR 4"; describes them as the cached
  DNSSEC-verified bytes or a DoH fetch.
- dmarc/test_vectors.rs: drops "PR 2" references in favor of the
  module path the fixtures live at.
- email_recovery/smtp.rs: pipeline summary mentions both DKIM-source
  paths (cached DNSSEC vs DoH); drops a stale "added during the PR 5
  review" historical note.
- email_recovery/remove.rs: TODO rephrased to be self-contained.

Comment-only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The DNSSEC path stores the DKIM TXT (and optionally DMARC TXT) bytes
on each pending entry so `smtp_request` can skip the DoH outcall.
Those bytes come straight from the verified RDATA — `verify_dnssec_chain`
authenticates them but doesn't bound their size. An attacker who
controls a signed zone could publish a multi-KB TXT record, push it
through prepare_add, and inflate every pending entry; at the
10 000-slot map cap that compounds.

Add pragmatic caps on the cached bytes:
- `MAX_DKIM_TXT_BYTES = 4096` — ~10× a real RSA-4096 DKIM record.
- `MAX_DMARC_TXT_BYTES = 1024` — ~2× the largest realistic DMARC.

Enforced in `verify_dnssec_chain` after the chain validates and
before the bytes land on the pending challenge. Returns
`EmailVerificationFailed` with a clear message naming the leaf and
the cap.

The DoH path is already bounded — `MAX_DOH_RESPONSE_BYTES = 4096`
caps the per-outcall response, and the DoH-fetched bytes aren't
stored on a pending entry anyway (they're consumed during
`smtp_request` only).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The current canister API still caps a recovery email at one entry per
anchor; this only changes the data model shape so multi-credential
support can land later without another schema bump.

- `Anchor.email_recovery: Option<EmailRecoveryCredential>` →
  `Vec<EmailRecoveryCredential>`. Empty vec replaces \`None\`.
- `StorableAnchor.email_recovery: Option<StorableEmailRecoveryCredential>` →
  `Option<Vec<StorableEmailRecoveryCredential>>` — same wrapping as
  `passkey_credentials` / `recovery_keys` so anchors written under the
  prior schema decode cleanly (`Vec<T>` alone would `MissingValue` on
  decode; verified empirically against this minicbor-derive version).
- Bind / remove logic and storage round-trip tests updated for the
  new shape.
Two-phase email-recovery only ever needs one leaf per call (the
optional DMARC TXT at prepare time, the DKIM TXT at submit_dkim_leaf
time), so a `Vec<SignedRRset>` was always-overkill — and a smaller
candid argument is a meaningful ingress saving for FE callers. Narrow
`DnsProofBundle.leaves: Vec<SignedRRset>` to
`DnsProofBundle.leaf: Option<SignedRRset>` across:

- `internet_identity_interface::types::DnsProofBundle` (candid + Rust)
- `crate::dnssec::types::DnsProofBundle` (verifier-internal)
- `interface_conversions` From<> impl
- `verify_with_clock` now returns `Result<VerifiedRecord, _>`
  (single record); `bundle.leaf = None` triggers Malformed
- The `verify` wall-clock wrapper follows the same return shape
- The `WireBundle` JSON loader and the `cloudflare-com-2026-05.json`
  test vector switch from `"leaves": [{...}]` to `"leaf": {...}`
- The `capture-dnssec-chain.py` capture script emits the new shape
- The DNSSEC-path happy-path integration test composes a
  single-leaf bundle inline (the helper now exposes `skeleton`,
  `dkim_leaf`, and optional `dmarc_leaf` separately so two-phase
  callers can construct the bundle they need)
- `internet_identity.did`: `leaves : vec SignedRRset` →
  `leaf : opt SignedRRset`

`prepare.rs::verify_dnssec_chain` is the only consumer that
previously expected DKIM + DMARC in one bundle. It now handles the
single-leaf shape — picking the leaf by owner-name match — but
otherwise keeps the legacy "DKIM at prepare time" pipeline since
the two-phase rewrite ships in the next commit. The integration
test still exercises that legacy pipeline.

All 13 dnssec unit tests still pass. The legacy DNSSEC happy-path
integration test was updated to construct its bundle from the new
`ChainOut` components.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
sea-snake and others added 7 commits May 11, 2026 16:00
Drops the FE selector-probing problem entirely: at prepare time the
FE submits a DNSSEC *skeleton* (chain only, optionally with the
DMARC leaf), and the canister caches the validated zone DNSKEY. When
the email arrives the canister parses `s=` from the DKIM-Signature
header — that's the authoritative selector — flips polled status to
`NeedDkimLeaf { selector }`, and the FE walks DNSSEC for that one
leaf and submits it via the new `email_recovery_submit_dkim_leaf`
update. See `docs/ongoing/email-recovery.md` §8.4 / §8.5.

What landed:

- **Interface types** (`internet_identity_interface`):
  - `EmailRecoveryDnsInput.selector` removed.
  - `EmailRecoveryStatus::NeedDkimLeaf { selector }` added (mid-flow).
  - `EmailRecoveryError::SelectorMismatch` removed; replaced by
    `DkimLeafMismatch` and `NoDkimLeafExpected`.
  - `EmailRecoverySubmitDkimLeafArg { nonce, dkim_leaf }` added.

- **Pending state** (`email_recovery::pending`):
  - `PendingChallenge.selector` and `cached_dkim_txt` removed.
  - `registered_domain`, `cached_zone_dnskey: Option<SignedRRset>`,
    and `partial_verification: Option<PartialVerification>` added.
  - `PartialVerification { headers_digest 32 B + signature blob +
    selector + signing_domain + algorithm + from_address_lc +
    subject_signed }` (~500 B per pending entry).
  - `PendingStatus::NeedDkimLeaf { selector }` added.

- **prepare.rs**:
  - Drops selector input.
  - Validates skeleton chain via `verify_chain_with_clock`, caching
    the deepest-zone DNSKEY.
  - Optionally validates a DMARC leaf at `_dmarc.<domain>`; rejects
    any other leaf (including DKIM, which now belongs in the
    submit-leaf call).

- **smtp.rs** (largely rewritten):
  - On the DNSSEC path: parses `DKIM-Signature`, verifies `bh=`,
    builds the canonical signed-headers input, computes its
    SHA-256, and stashes `PartialVerification`. Body is dropped
    once `bh=` validates. Pins From↔claimed_address and
    `Subject` in `h=` early. Flips status to
    `NeedDkimLeaf { selector }`.
  - On the DoH path: behaves as before — fetch DKIM TXT, run
    `dmarc::verify_email`, bind, finish synchronously.
  - Idempotent: redelivered emails are silent no-ops once the
    challenge has advanced past `Pending`.
  - `bind_credential_to_anchor` → `bind_credential` taking
    primitives so submit-leaf can reuse it.

- **submit_leaf.rs** (new module):
  - Validates the FE-supplied `SignedRRset` against the cached
    zone DNSKEY (no chain re-walk).
  - Confirms the leaf's owner name is exactly
    `<expected_selector>._domainkey.<registered_domain>.`.
  - Parses the DKIM TXT, runs the cryptographic signature check
    using a new prehash-aware helper:
    - RSA-SHA256: `RsaVerifyingKey::verify_prehash` (PKCS#1 v1.5).
    - Ed25519-SHA256: RFC 8463 — Ed25519 verify directly over
      the cached SHA-256 digest.
  - Verifies DMARC alignment against the cached DMARC bytes (or
    falls back to strict `d=` ↔ From: equality when no DMARC
    was cached).
  - Binds the credential and flips status to `Succeeded`.

- **dnssec verifier**: chain/leaf split landed in a prior commit; this
  commit consumes both halves.

- **DKIM/DMARC**: a few helpers raised to `pub(crate)` so smtp.rs and
  submit_leaf.rs can compose the parts they need without re-running
  the full single-pass verifier on a body we've already dropped.

- **main.rs**: `email_recovery_submit_dkim_leaf(arg)` update
  endpoint added — anonymous (the nonce is the only authentication;
  it's a 64-bit canister-issued secret bound to one pending entry).

- **.did** + regenerated FE bindings.

All 441 canister unit tests pass. The integration-test happy-path
fixture compiles but its runtime expectation (single-shot DKIM-leaf
bundle to prepare) is no longer correct under the two-phase flow —
the test needs a rewrite to exercise prepare → smtp_request →
submit_dkim_leaf in sequence. That rewrite ships with the FE work
that exercises the same endpoints.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Multiple lint failures across the stack were masked because earlier
CI jobs (clippy) short-circuited before the later jobs (fmt,
frontend-checks) got to run. Fix them all in one pass:

- **clippy `doc_lazy_continuation`**: the
  `EmailRecoverySubmitDkimLeafArg` doc comment had a line starting
  with `+ signature blob)` — markdown saw the `+ ` as a list
  bullet and complained that the next lines weren't indented as
  continuation. Reflow the prose to drop the line break.
- **clippy `unnecessary closure on Option::None`**: collapse a
  `ok_or_else(|| ...)` to `ok_or(...)` in submit_leaf.rs.
- **fmt drift on existing files**: `dnssec/mod.rs`, `prepare.rs`,
  `submit_leaf.rs`, `email_recovery.rs`, plus a few outside the
  email-recovery surface (`openid/generic.rs`, `attributes.rs`,
  `oidc_configs.rs`) that previously slipped past CI. `cargo fmt`
  noop, just whitespace reflow.
- **frontend-checks tsc error**: the mock
  `InternetIdentityInit` literal in
  `iiConnection.test.ts` and the actor-construction call in
  `vc-flow/index/+page.svelte` were missing the `doh_config` and
  `dnssec_config` fields that landed in earlier PRs in this
  stack. Add them as `[]` (the `opt opt T` shape's empty form).

All 441 unit tests still pass; FE `npm run lint` is clean (0
errors); `svelte-check` reports 0 errors; `cargo fmt --check`
clean; `cargo clippy --workspace --all-targets -- -D warnings`
clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The integration test \`full_setup_flow_via_dnssec_path\` was still
sending a DKIM-leaf bundle to \`prepare_add\`, which the canister
now rejects (skeleton bundles only carry DMARC at most; the DKIM
leaf belongs in \`submit_dkim_leaf\` post-email). Six shards of
canister-tests-run failed on it.

Rewrite the test to drive the full two-phase flow:
  1. prepare_add with skeleton + DMARC leaf
  2. smtp_request — body validates, status flips to NeedDkimLeaf
  3. assert NeedDkimLeaf{selector} matches the test selector
  4. submit_dkim_leaf with the DKIM leaf bundle → returns
     RegistrationSucceeded
  5. polled status confirms; remove path works

Also adds the matching \`email_recovery_submit_dkim_leaf\` helper
to canister_tests/src/api/internet_identity.rs.
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 (dfinity#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>
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 bundle's `chain: Vec<DelegationLink>` / `leaf: Option<SignedRRset>`
got replaced by `chains: Vec<DelegationChain>` / `hops: Vec<SignedRRset>`
when the multi-zone + CNAME-aware verifier landed in PR 1. Bring the
storage-and-smtp consumers in line:

- internet_identity_interface::types::dnssec: new bundle shape +
  the new `DelegationChain { links: Vec<DelegationLink> }` wrapper,
  CandidType-derived for the FE.
- internet_identity.did: matching candid type definitions.
- dnssec::types::interface_conversions: From<> bridge for the new
  shape (CandidType-derived i_types → verifier-internal types).
- email_recovery::prepare::verify_dnssec_skeleton: was using the
  old `verify_chain_with_clock(bundle, anchors, now)` (returned the
  zone DNSKEY directly) plus `verify_leaf_against_dnskey(...)` for
  the optional DMARC. Now uses the new entry points:
    1) verify_root_dnskey_with_clock for the anchors check,
    2) verify_extra_chains_with_clock to walk the chains into a
       ZoneKeysMap,
    3) verify_hops_with_clock for the optional DMARC hop.
  Caches the single skeleton-zone DNSKEY for the submit phase via
  ZoneKeysMap::iter() (added in PR 1).
- email_recovery::submit_leaf::run_submit: was calling
  verify_leaf_against_dnskey directly against the cached zone
  DNSKEY. Now wraps the cached DNSKEY in a 1-entry ZoneKeysMap and
  the DKIM leaf in a 1-hop slice, calls verify_hops_with_clock.
- integration tests: update the three places that construct a
  `DnsProofBundle` literal in tests (allowlist-precedence test,
  happy-path-via-dnssec helper, in-test signer's `skeleton` field).

460 tests pass; cargo clippy clean with -D warnings.
@sea-snake sea-snake force-pushed the feat/email-recovery-storage-and-smtp branch from 561ecb7 to f04ea77 Compare May 11, 2026 16:01
@sea-snake sea-snake changed the base branch from main to feat/doh-fallback 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
CI failure (`expected RegistrationSucceeded, got Failed(...DnsRecordMalformed("RSA SPKI decode...")...)`):
- The in-test DKIM signer was encoding the public key as PKCS#1
  RsaPublicKey, but the canister verifier expects an X.509
  SubjectPublicKeyInfo (SPKI) per RFC 5280 §4.1 — which is also what
  real DKIM records publish in the `p=` tag. Switch to
  `to_public_key_der()`.

Copilot review comments addressed:

- `EmailRecoveryChallenge.expires_at` now in **nanoseconds** since
  epoch (matches `Timestamp` semantics — was off by a factor of 1e9).
- Non-allowlisted domain at prepare time now returns
  `DomainNotAllowlisted(domain)` instead of `DomainNotSupported`.
  `DomainNotSupported` is reserved for genuinely unsupported domain
  shapes (and for the future DNSSEC-attempted-but-failed case).
- `smtp_request` recipient dispatch now matches on the **full**
  recipient address (user + domain), case-insensitive on both
  halves. New `SETUP_RECIPIENT_DOMAIN = "id.ai"` constant. A direct
  caller can no longer set `to.user="register"` with an arbitrary
  domain to bypass dispatch.
- `smtp_request` now maps `DohError` variants to typed
  `EmailRecoveryError`s: `NotConfigured` / `DomainNotAllowed` →
  `DomainNotAllowlisted` (so the FE shows "operator hasn't enabled
  this domain"); `AllProvidersFailed` / `QuorumFailed` /
  `ResponseMalformed` → `DohFetchFailed` (transient-error UX);
  `InvalidName` / `NameOutsideRegisteredDomain` →
  `InternalCanisterError` (caller-bug variants `prepare_add` should
  have caught).
- Doc fixes:
  - Interface module-level: `5-of-3 quorum` → `3-of-5 quorum`.
  - `EmailRecoveryCredential` and `StorableEmailRecoveryCredential`:
    `created_at` / `last_used` units corrected from "Unix-seconds"
    to "nanoseconds since epoch".
  - `EmailRecoveryChallenge.nonce` doc: "~16 base32 characters" →
    "16 lowercase hex characters (8 random bytes)".
  - `EmailRecoveryChallenge.expires_at` doc: "Unix seconds" →
    "nanoseconds since epoch".
  - `email_recovery::remove`: doc no longer claims to be wrapped in
    `anchor_operation_with_authz_check` — that helper isn't used at
    the canister-method layer (orphan-rule constraint on the
    interface-crate error type, see `main.rs::email_recovery_api`).

Tests updated for the new units; 441 unit tests still pass.
sea-snake added a commit that referenced this pull request May 12, 2026
Adds the DNSSEC verification path to the setup flow. The FE can now
optionally pass a `DnsProofBundle` alongside the address+selector;
when supplied, the canister:

1. Validates the chain synchronously against its configured
   `DnssecConfig.root_anchors` at prepare time.
2. Confirms the bundle's leaf is a TXT record at the expected
   `<selector>._domainkey.<registered_domain>` name (otherwise an
   attacker who got a chain validated for *some* TXT record could
   bind that record's content as a DKIM key).
3. Caches the verified TXT bytes on the pending challenge.

`smtp_request` then uses the cached TXT directly — no DoH outcall
fan-out at email-arrival time. When `dns_proof` is absent, falls
through to the existing DoH allowlist path unchanged.

Wire-level changes:

- `internet_identity_interface::types::dnssec`: adds Candid-friendly
  mirror types for `DnsProofBundle`, `SignedRRset`, `Rrsig`,
  `DelegationLink`. Wire-format owner names are `blob`s; the
  canister-side verifier types stay as-is and are bridged via
  `From<>` impls in `dnssec::types::interface_conversions`.
- `EmailRecoveryDnsInput` gains an optional `dns_proof:
  Option<DnsProofBundle>` field. Forward-compatible Candid extension.
- `.did` declares the new types.
- `dnssec::types`: adds `TYPE_TXT` / `TYPE_DS` / `TYPE_DNSKEY`
  constants and re-exports them from `dnssec::mod`.
- `dnssec::mod`: makes the `types` submodule `pub(crate)` so the
  email-recovery module can reach the internal `DnsProofBundle`
  via `From` conversions.
- `PendingChallenge` gains `cached_dkim_txt: Option<Vec<u8>>` so
  `smtp_request` knows whether to skip the DoH path.

Two new PocketIC integration tests cover the plumbing end-to-end:

- `dnssec_path_rejects_when_no_trust_anchors_configured` — supplies
  a structurally-valid bundle without anchor config; expects
  `DomainNotSupported("…trust anchors not configured…")`.
- `dnssec_path_takes_precedence_over_doh_allowlist` — supplies a
  malformed bundle for a domain that *is* on the DoH allowlist;
  expects DNSSEC failure rather than fall-through to DoH.

A happy-path DNSSEC test (with a real `<selector>._domainkey.<domain>`
chain) is a follow-up — generating a DNSSEC chain at test time would
be a multi-hundred-line in-test signer; the existing
`crate::dnssec::test_vectors` cloudflare.com fixture's leaf is a
plain `cloudflare.com` TXT, not a `_domainkey` TXT.

Also addresses the earlier Copilot review feedback on PR #3842 (in a
separate commit on this branch): expires_at units, error variant
mapping, recipient-dispatch full-address match, DoH error mapping.
sea-snake added a commit that referenced this pull request May 12, 2026
Several module-level and inline comments still claimed DoH was the
only path, that DNSSEC was deferred, that callers hadn't landed, or
referenced specific PR numbers in the now-collapsed stack. Now that
PR #3842 landed both paths, the on-anchor credential, and the DKIM /
DMARC / DoH / DNSSEC consumers, the comments needed to catch up.

Concrete updates:
- main.rs: prepare_add doc no longer says "DoH path only in this PR";
  describes the actual path picker.
- email_recovery.rs interface module + EmailRecoveryDnsInput doc:
  drops "DNSSEC deferred to a follow-up PR" / "DoH only in initial
  cut" framing; now describes both paths as live with the canister
  picking per call.
- dkim/mod.rs, dmarc/mod.rs, dnssec/mod.rs: dead-code-allow rationale
  rewritten to point at the actual in-canister consumer
  (`crate::email_recovery::smtp::verify_setup_email`) instead of
  promising future PRs.
- dkim/verify.rs, dmarc/verify.rs: data-flow doc no longer lists
  inputs as coming from "PR 1 / PR 4"; describes them as the cached
  DNSSEC-verified bytes or a DoH fetch.
- dmarc/test_vectors.rs: drops "PR 2" references in favor of the
  module path the fixtures live at.
- email_recovery/smtp.rs: pipeline summary mentions both DKIM-source
  paths (cached DNSSEC vs DoH); drops a stale "added during the PR 5
  review" historical note.
- email_recovery/remove.rs: TODO rephrased to be self-contained.

Comment-only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
sea-snake added a commit that referenced this pull request May 12, 2026
CI failure (`expected RegistrationSucceeded, got Failed(...DnsRecordMalformed("RSA SPKI decode...")...)`):
- The in-test DKIM signer was encoding the public key as PKCS#1
  RsaPublicKey, but the canister verifier expects an X.509
  SubjectPublicKeyInfo (SPKI) per RFC 5280 §4.1 — which is also what
  real DKIM records publish in the `p=` tag. Switch to
  `to_public_key_der()`.

Copilot review comments addressed:

- `EmailRecoveryChallenge.expires_at` now in **nanoseconds** since
  epoch (matches `Timestamp` semantics — was off by a factor of 1e9).
- Non-allowlisted domain at prepare time now returns
  `DomainNotAllowlisted(domain)` instead of `DomainNotSupported`.
  `DomainNotSupported` is reserved for genuinely unsupported domain
  shapes (and for the future DNSSEC-attempted-but-failed case).
- `smtp_request` recipient dispatch now matches on the **full**
  recipient address (user + domain), case-insensitive on both
  halves. New `SETUP_RECIPIENT_DOMAIN = "id.ai"` constant. A direct
  caller can no longer set `to.user="register"` with an arbitrary
  domain to bypass dispatch.
- `smtp_request` now maps `DohError` variants to typed
  `EmailRecoveryError`s: `NotConfigured` / `DomainNotAllowed` →
  `DomainNotAllowlisted` (so the FE shows "operator hasn't enabled
  this domain"); `AllProvidersFailed` / `QuorumFailed` /
  `ResponseMalformed` → `DohFetchFailed` (transient-error UX);
  `InvalidName` / `NameOutsideRegisteredDomain` →
  `InternalCanisterError` (caller-bug variants `prepare_add` should
  have caught).
- Doc fixes:
  - Interface module-level: `5-of-3 quorum` → `3-of-5 quorum`.
  - `EmailRecoveryCredential` and `StorableEmailRecoveryCredential`:
    `created_at` / `last_used` units corrected from "Unix-seconds"
    to "nanoseconds since epoch".
  - `EmailRecoveryChallenge.nonce` doc: "~16 base32 characters" →
    "16 lowercase hex characters (8 random bytes)".
  - `EmailRecoveryChallenge.expires_at` doc: "Unix seconds" →
    "nanoseconds since epoch".
  - `email_recovery::remove`: doc no longer claims to be wrapped in
    `anchor_operation_with_authz_check` — that helper isn't used at
    the canister-method layer (orphan-rule constraint on the
    interface-crate error type, see `main.rs::email_recovery_api`).

Tests updated for the new units; 441 unit tests still pass.
sea-snake added a commit that referenced this pull request May 12, 2026
Adds the DNSSEC verification path to the setup flow. The FE can now
optionally pass a `DnsProofBundle` alongside the address+selector;
when supplied, the canister:

1. Validates the chain synchronously against its configured
   `DnssecConfig.root_anchors` at prepare time.
2. Confirms the bundle's leaf is a TXT record at the expected
   `<selector>._domainkey.<registered_domain>` name (otherwise an
   attacker who got a chain validated for *some* TXT record could
   bind that record's content as a DKIM key).
3. Caches the verified TXT bytes on the pending challenge.

`smtp_request` then uses the cached TXT directly — no DoH outcall
fan-out at email-arrival time. When `dns_proof` is absent, falls
through to the existing DoH allowlist path unchanged.

Wire-level changes:

- `internet_identity_interface::types::dnssec`: adds Candid-friendly
  mirror types for `DnsProofBundle`, `SignedRRset`, `Rrsig`,
  `DelegationLink`. Wire-format owner names are `blob`s; the
  canister-side verifier types stay as-is and are bridged via
  `From<>` impls in `dnssec::types::interface_conversions`.
- `EmailRecoveryDnsInput` gains an optional `dns_proof:
  Option<DnsProofBundle>` field. Forward-compatible Candid extension.
- `.did` declares the new types.
- `dnssec::types`: adds `TYPE_TXT` / `TYPE_DS` / `TYPE_DNSKEY`
  constants and re-exports them from `dnssec::mod`.
- `dnssec::mod`: makes the `types` submodule `pub(crate)` so the
  email-recovery module can reach the internal `DnsProofBundle`
  via `From` conversions.
- `PendingChallenge` gains `cached_dkim_txt: Option<Vec<u8>>` so
  `smtp_request` knows whether to skip the DoH path.

Two new PocketIC integration tests cover the plumbing end-to-end:

- `dnssec_path_rejects_when_no_trust_anchors_configured` — supplies
  a structurally-valid bundle without anchor config; expects
  `DomainNotSupported("…trust anchors not configured…")`.
- `dnssec_path_takes_precedence_over_doh_allowlist` — supplies a
  malformed bundle for a domain that *is* on the DoH allowlist;
  expects DNSSEC failure rather than fall-through to DoH.

A happy-path DNSSEC test (with a real `<selector>._domainkey.<domain>`
chain) is a follow-up — generating a DNSSEC chain at test time would
be a multi-hundred-line in-test signer; the existing
`crate::dnssec::test_vectors` cloudflare.com fixture's leaf is a
plain `cloudflare.com` TXT, not a `_domainkey` TXT.

Also addresses the earlier Copilot review feedback on PR #3842 (in a
separate commit on this branch): expires_at units, error variant
mapping, recipient-dispatch full-address match, DoH error mapping.
sea-snake added a commit that referenced this pull request May 12, 2026
Several module-level and inline comments still claimed DoH was the
only path, that DNSSEC was deferred, that callers hadn't landed, or
referenced specific PR numbers in the now-collapsed stack. Now that
PR #3842 landed both paths, the on-anchor credential, and the DKIM /
DMARC / DoH / DNSSEC consumers, the comments needed to catch up.

Concrete updates:
- main.rs: prepare_add doc no longer says "DoH path only in this PR";
  describes the actual path picker.
- email_recovery.rs interface module + EmailRecoveryDnsInput doc:
  drops "DNSSEC deferred to a follow-up PR" / "DoH only in initial
  cut" framing; now describes both paths as live with the canister
  picking per call.
- dkim/mod.rs, dmarc/mod.rs, dnssec/mod.rs: dead-code-allow rationale
  rewritten to point at the actual in-canister consumer
  (`crate::email_recovery::smtp::verify_setup_email`) instead of
  promising future PRs.
- dkim/verify.rs, dmarc/verify.rs: data-flow doc no longer lists
  inputs as coming from "PR 1 / PR 4"; describes them as the cached
  DNSSEC-verified bytes or a DoH fetch.
- dmarc/test_vectors.rs: drops "PR 2" references in favor of the
  module path the fixtures live at.
- email_recovery/smtp.rs: pipeline summary mentions both DKIM-source
  paths (cached DNSSEC vs DoH); drops a stale "added during the PR 5
  review" historical note.
- email_recovery/remove.rs: TODO rephrased to be self-contained.

Comment-only.

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
CI failure (`expected RegistrationSucceeded, got Failed(...DnsRecordMalformed("RSA SPKI decode...")...)`):
- The in-test DKIM signer was encoding the public key as PKCS#1
  RsaPublicKey, but the canister verifier expects an X.509
  SubjectPublicKeyInfo (SPKI) per RFC 5280 §4.1 — which is also what
  real DKIM records publish in the `p=` tag. Switch to
  `to_public_key_der()`.

Copilot review comments addressed:

- `EmailRecoveryChallenge.expires_at` now in **nanoseconds** since
  epoch (matches `Timestamp` semantics — was off by a factor of 1e9).
- Non-allowlisted domain at prepare time now returns
  `DomainNotAllowlisted(domain)` instead of `DomainNotSupported`.
  `DomainNotSupported` is reserved for genuinely unsupported domain
  shapes (and for the future DNSSEC-attempted-but-failed case).
- `smtp_request` recipient dispatch now matches on the **full**
  recipient address (user + domain), case-insensitive on both
  halves. New `SETUP_RECIPIENT_DOMAIN = "id.ai"` constant. A direct
  caller can no longer set `to.user="register"` with an arbitrary
  domain to bypass dispatch.
- `smtp_request` now maps `DohError` variants to typed
  `EmailRecoveryError`s: `NotConfigured` / `DomainNotAllowed` →
  `DomainNotAllowlisted` (so the FE shows "operator hasn't enabled
  this domain"); `AllProvidersFailed` / `QuorumFailed` /
  `ResponseMalformed` → `DohFetchFailed` (transient-error UX);
  `InvalidName` / `NameOutsideRegisteredDomain` →
  `InternalCanisterError` (caller-bug variants `prepare_add` should
  have caught).
- Doc fixes:
  - Interface module-level: `5-of-3 quorum` → `3-of-5 quorum`.
  - `EmailRecoveryCredential` and `StorableEmailRecoveryCredential`:
    `created_at` / `last_used` units corrected from "Unix-seconds"
    to "nanoseconds since epoch".
  - `EmailRecoveryChallenge.nonce` doc: "~16 base32 characters" →
    "16 lowercase hex characters (8 random bytes)".
  - `EmailRecoveryChallenge.expires_at` doc: "Unix seconds" →
    "nanoseconds since epoch".
  - `email_recovery::remove`: doc no longer claims to be wrapped in
    `anchor_operation_with_authz_check` — that helper isn't used at
    the canister-method layer (orphan-rule constraint on the
    interface-crate error type, see `main.rs::email_recovery_api`).

Tests updated for the new units; 441 unit tests still pass.
sea-snake added a commit that referenced this pull request May 13, 2026
Adds the DNSSEC verification path to the setup flow. The FE can now
optionally pass a `DnsProofBundle` alongside the address+selector;
when supplied, the canister:

1. Validates the chain synchronously against its configured
   `DnssecConfig.root_anchors` at prepare time.
2. Confirms the bundle's leaf is a TXT record at the expected
   `<selector>._domainkey.<registered_domain>` name (otherwise an
   attacker who got a chain validated for *some* TXT record could
   bind that record's content as a DKIM key).
3. Caches the verified TXT bytes on the pending challenge.

`smtp_request` then uses the cached TXT directly — no DoH outcall
fan-out at email-arrival time. When `dns_proof` is absent, falls
through to the existing DoH allowlist path unchanged.

Wire-level changes:

- `internet_identity_interface::types::dnssec`: adds Candid-friendly
  mirror types for `DnsProofBundle`, `SignedRRset`, `Rrsig`,
  `DelegationLink`. Wire-format owner names are `blob`s; the
  canister-side verifier types stay as-is and are bridged via
  `From<>` impls in `dnssec::types::interface_conversions`.
- `EmailRecoveryDnsInput` gains an optional `dns_proof:
  Option<DnsProofBundle>` field. Forward-compatible Candid extension.
- `.did` declares the new types.
- `dnssec::types`: adds `TYPE_TXT` / `TYPE_DS` / `TYPE_DNSKEY`
  constants and re-exports them from `dnssec::mod`.
- `dnssec::mod`: makes the `types` submodule `pub(crate)` so the
  email-recovery module can reach the internal `DnsProofBundle`
  via `From` conversions.
- `PendingChallenge` gains `cached_dkim_txt: Option<Vec<u8>>` so
  `smtp_request` knows whether to skip the DoH path.

Two new PocketIC integration tests cover the plumbing end-to-end:

- `dnssec_path_rejects_when_no_trust_anchors_configured` — supplies
  a structurally-valid bundle without anchor config; expects
  `DomainNotSupported("…trust anchors not configured…")`.
- `dnssec_path_takes_precedence_over_doh_allowlist` — supplies a
  malformed bundle for a domain that *is* on the DoH allowlist;
  expects DNSSEC failure rather than fall-through to DoH.

A happy-path DNSSEC test (with a real `<selector>._domainkey.<domain>`
chain) is a follow-up — generating a DNSSEC chain at test time would
be a multi-hundred-line in-test signer; the existing
`crate::dnssec::test_vectors` cloudflare.com fixture's leaf is a
plain `cloudflare.com` TXT, not a `_domainkey` TXT.

Also addresses the earlier Copilot review feedback on PR #3842 (in a
separate commit on this branch): expires_at units, error variant
mapping, recipient-dispatch full-address match, DoH error mapping.
sea-snake added a commit that referenced this pull request May 13, 2026
Several module-level and inline comments still claimed DoH was the
only path, that DNSSEC was deferred, that callers hadn't landed, or
referenced specific PR numbers in the now-collapsed stack. Now that
PR #3842 landed both paths, the on-anchor credential, and the DKIM /
DMARC / DoH / DNSSEC consumers, the comments needed to catch up.

Concrete updates:
- main.rs: prepare_add doc no longer says "DoH path only in this PR";
  describes the actual path picker.
- email_recovery.rs interface module + EmailRecoveryDnsInput doc:
  drops "DNSSEC deferred to a follow-up PR" / "DoH only in initial
  cut" framing; now describes both paths as live with the canister
  picking per call.
- dkim/mod.rs, dmarc/mod.rs, dnssec/mod.rs: dead-code-allow rationale
  rewritten to point at the actual in-canister consumer
  (`crate::email_recovery::smtp::verify_setup_email`) instead of
  promising future PRs.
- dkim/verify.rs, dmarc/verify.rs: data-flow doc no longer lists
  inputs as coming from "PR 1 / PR 4"; describes them as the cached
  DNSSEC-verified bytes or a DoH fetch.
- dmarc/test_vectors.rs: drops "PR 2" references in favor of the
  module path the fixtures live at.
- email_recovery/smtp.rs: pipeline summary mentions both DKIM-source
  paths (cached DNSSEC vs DoH); drops a stale "added during the PR 5
  review" historical note.
- email_recovery/remove.rs: TODO rephrased to be self-contained.

Comment-only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants