@@ -299,6 +299,51 @@ type InternetIdentityInit = record {
299299 backend_canister_id : opt principal;
300300 // Backend origin, needed to sync configuration with frontend.
301301 backend_origin : opt text;
302+ // DNSSEC verification configuration. Trust anchors used by any feature
303+ // that verifies DNS records against the IANA-rooted DNSSEC chain
304+ // (currently the email-recovery DKIM/DMARC flow). See
305+ // `docs/ongoing/email-recovery.md` §7.5.
306+ //
307+ // Wrapped in `opt opt` to match the same set/clear pattern as
308+ // `analytics_config` / `dummy_auth`: outer null keeps the previously
309+ // stored value across an upgrade, `opt null` clears it, `opt opt c`
310+ // sets it to `c`.
311+ dnssec_config : opt opt DnssecConfig;
312+ // DoH (DNS-over-HTTPS) fallback configuration. Allowlists the
313+ // domains for which the canister may fetch DKIM/DMARC TXT records
314+ // via HTTP outcalls when no DNSSEC chain is available — see
315+ // `docs/ongoing/email-recovery.md` §7.6. Same set/clear pattern.
316+ doh_config : opt opt DohConfig;
317+ };
318+
319+ // DNSSEC trust-anchor list. Any feature that needs DNSSEC-verified DNS
320+ // records consumes the same anchors; not specific to email recovery.
321+ type DnssecConfig = record {
322+ // IANA root KSK trust anchors. Multiple are accepted simultaneously so
323+ // KSK rollover is a single config change in the next upgrade arg.
324+ root_anchors : vec DnssecRootAnchor;
325+ };
326+
327+ // One IANA root KSK trust anchor, in the same shape IANA publishes at
328+ // `data.iana.org/root-anchors/root-anchors.xml`. Only `digest_type = 2`
329+ // (SHA-256) is accepted; the legacy SHA-1 form is rejected at the
330+ // verifier boundary.
331+ type DnssecRootAnchor = record {
332+ key_tag : nat16;
333+ algorithm : nat8;
334+ digest_type : nat8;
335+ digest : blob;
336+ };
337+
338+ // DoH (DNS-over-HTTPS) fallback configuration.
339+ //
340+ // The canister will only fetch DKIM/DMARC TXT records via HTTP outcalls
341+ // for an FQDN whose registered domain is in `allowed_domains`. Cache
342+ // entries are populated on demand and re-used until `max_cache_age_secs`
343+ // elapses (default 3600s when null, capped at 24h).
344+ type DohConfig = record {
345+ allowed_domains : vec text;
346+ max_cache_age_secs : opt nat64;
302347};
303348
304349type ChallengeKey = text;
@@ -387,6 +432,14 @@ type OpenIdConfig = record {
387432 auth_scope : vec text;
388433 fedcm_uri : opt text;
389434 email_verification : opt OpenIdEmailVerification;
435+ // Optional initial set of JWKs used to seed this provider's JWK cache on
436+ // install, so JWT verification works before the first jwks_uri fetch and
437+ // across upgrades (the cache is persisted in stable memory). The outer vec
438+ // is the set of JWKs; each inner vec is one JWK, given as the list of its
439+ // JSON (field, value) pairs, e.g. a single RSA key is
440+ // vec { vec { record { "kty"; "RSA" }; record { "kid"; "..." };
441+ // record { "n"; "..." }; record { "e"; "AQAB" } } }.
442+ seed_jwks : opt vec vec record { text; text };
390443};
391444
392445// SSO provider config that uses two-hop discovery.
@@ -462,6 +515,191 @@ type OpenIdPrepareDelegationResponse = record {
462515 anchor_number : UserNumber;
463516};
464517
518+ // Email-recovery types
519+ // ====================
520+ // See `docs/ongoing/email-recovery.md` for the full design. Covers
521+ // both halves of the flow: setup (binding a recovery email to an
522+ // anchor) and recovery (proving control of a previously-bound
523+ // address to obtain a signed delegation).
524+
525+ type EmailRecoveryCredential = record {
526+ address : text;
527+ created_at : Timestamp;
528+ last_used : opt Timestamp;
529+ };
530+
531+ type EmailRecoveryChallenge = record {
532+ nonce : text;
533+ expires_at : Timestamp;
534+ };
535+
536+ type EmailRecoveryDnsInput = record {
537+ address : text;
538+ dns_proof : opt DnsProofBundle;
539+ };
540+
541+ type EmailRecoverySubmitDkimLeafArg = record {
542+ nonce : text;
543+ // The DKIM resolution chain in CNAME order, ending in a TXT. At
544+ // least one hop required; bounded by `MAX_CNAME_HOPS = 4` at the
545+ // canister side. For the Gmail-style direct-TXT case this is a
546+ // single-element vec.
547+ hops : vec SignedRRset;
548+ // Delegation chains for signed zones touched by `hops` that
549+ // weren't already covered by the skeleton chain anchored at
550+ // prepare time. Empty for same-zone resolution.
551+ extra_chains : vec DelegationChain;
552+ };
553+
554+ // DNSSEC proof bundle and supporting types — see
555+ // `internet_identity_interface::types::dnssec`.
556+ type Rrsig = record {
557+ type_covered : nat16;
558+ algorithm : nat8;
559+ labels : nat8;
560+ original_ttl : nat32;
561+ expiration : nat32;
562+ inception : nat32;
563+ key_tag : nat16;
564+ signer_name : blob;
565+ signature : blob;
566+ };
567+
568+ type SignedRRset = record {
569+ name : blob;
570+ rtype : nat16;
571+ rdata : vec blob;
572+ ttl : nat32;
573+ rrsig : Rrsig;
574+ };
575+
576+ type DelegationLink = record {
577+ child_ds : SignedRRset;
578+ child_dnskey : SignedRRset;
579+ };
580+
581+ type DelegationChain = record {
582+ links : vec DelegationLink;
583+ };
584+
585+ type DnsProofBundle = record {
586+ root_dnskey : SignedRRset;
587+ // One delegation chain per signing zone the bundle touches.
588+ // Single-zone direct case (Gmail, iCloud, …): one chain.
589+ // Cross-zone CNAME case (Proton, Tutanota, M365 custom domains):
590+ // one chain per signing zone touched.
591+ chains : vec DelegationChain;
592+ // The RRsets being authenticated, in CNAME-resolution order.
593+ // Single-leaf case: one hop. CNAME case: intermediate CNAMEs,
594+ // then the final TXT.
595+ hops : vec SignedRRset;
596+ };
597+
598+ type EmailRecoveryError = variant {
599+ Unauthorized : principal;
600+ NonceUnknown;
601+ NonceExpired;
602+ DomainNotAllowlisted : text;
603+ DohFetchFailed : text;
604+ DomainNotSupported : text;
605+ EmailVerificationFailed : text;
606+ DkimLeafMismatch;
607+ NoDkimLeafExpected;
608+ AddressMismatch;
609+ SubjectNotSigned;
610+ AddressAlreadyRegistered;
611+ AddressNotRegistered;
612+ InternalCanisterError : text;
613+ };
614+
615+ type EmailRecoveryStatus = variant {
616+ Pending;
617+ NeedDkimLeaf : record { selector : text };
618+ RegistrationSucceeded;
619+ RecoveryReady : record {
620+ user_key : UserKey;
621+ expiration : Timestamp;
622+ anchor_number : IdentityNumber;
623+ };
624+ Failed : EmailRecoveryError;
625+ Expired;
626+ };
627+
628+ type EmailRecoveryGetDelegationArgs = record {
629+ nonce : text;
630+ session_key : SessionKey;
631+ expiration : Timestamp;
632+ };
633+
634+ // SMTP gateway types — see `internet_identity_interface::smtp`. Carried
635+ // forward from PoC #3760 surface (the existing gateway can target this
636+ // canister without changes).
637+ type SmtpHeader = record {
638+ name : text;
639+ value : text;
640+ };
641+
642+ type SmtpMessage = record {
643+ headers : vec SmtpHeader;
644+ body : blob;
645+ };
646+
647+ type SmtpAddress = record {
648+ user : text;
649+ domain : text;
650+ };
651+
652+ type SmtpEnvelope = record {
653+ from : SmtpAddress;
654+ // SMTP allows multiple `RCPT TO` recipients per envelope, so this
655+ // is a vec at the wire level. For the recovery flows this canister
656+ // serves, however, we require *exactly one* recipient and it must
657+ // be `register@<domain>` or `recover@<domain>` — a legitimate
658+ // recovery email never targets a CC/BCC alongside us, so any
659+ // additional recipient can only come from a phishy forwarder
660+ // trying to exfiltrate the user's canister-signed challenge.
661+ // Multi-recipient envelopes (and empty ones) are rejected with
662+ // code 551 ("User not local"); single-recipient envelopes whose
663+ // recipient isn't one of our reserved mailboxes get 550 ("No
664+ // such user here"). The vec is also capped at 100 entries (RFC
665+ // 5321 §4.5.3.1.10); envelopes exceeding the cap are rejected
666+ // with code 555.
667+ to : vec SmtpAddress;
668+ };
669+
670+ type SmtpRequest = record {
671+ message : opt SmtpMessage;
672+ envelope : opt SmtpEnvelope;
673+ gateway_flags : opt vec text;
674+ };
675+
676+ // Error returned by `smtp_request` / `smtp_request_validate`.
677+ //
678+ // `code` mirrors the SMTP reply codes the off-chain gateway should
679+ // emit upstream:
680+ // - `550` (mailbox unavailable) — "No such user here". Returned when
681+ // the envelope carries exactly one recipient but it isn't a mailbox
682+ // this canister handles (i.e. neither `register@<domain>` nor
683+ // `recover@<domain>` for any `<domain>` in `related_origins`).
684+ // - `551` (user not local) — envelope-shape rejection. Returned for
685+ // empty `to` and for multi-recipient envelopes, even when one of
686+ // the recipients is ours. Distinct from 550 so the gateway can tell
687+ // "this envelope shape isn't accepted" from "we don't know this
688+ // user". Recovery emails never legitimately address a CC/BCC
689+ // alongside `register@…` / `recover@…`.
690+ // - `555` (syntax error) — the request shape itself is malformed
691+ // (e.g. missing envelope, oversize address/header/body, recipient
692+ // list exceeds the 100-entry cap).
693+ type SmtpRequestError = record {
694+ code : nat64;
695+ message : text;
696+ };
697+
698+ type SmtpResponse = variant {
699+ Ok : record {};
700+ Err : SmtpRequestError;
701+ };
702+
465703// API V2 specific types
466704// WARNING: These type are experimental and may change in the future.
467705type IdentityNumber = nat64;
@@ -621,6 +859,12 @@ type IdentityInfo = record {
621859 name : opt text;
622860 // The timestamp at which the anchor was created
623861 created_at : opt Timestamp;
862+ // Email-recovery credentials bound to this anchor (absent when
863+ // none is configured). The canister API currently caps the list
864+ // at one entry — the FE renders the recovery-email card from
865+ // the first one — but exposing it as a `vec` lets future
866+ // multi-credential support land without a candid schema bump.
867+ email_recovery : opt vec EmailRecoveryCredential;
624868};
625869
626870type IdentityInfoError = variant {
@@ -1230,6 +1474,41 @@ service : (opt InternetIdentityInit) -> {
12301474 openid_prepare_delegation : (JWT, Salt, SessionKey) -> (variant { Ok : OpenIdPrepareDelegationResponse; Err : OpenIdDelegationError });
12311475 openid_get_delegation : (JWT, Salt, SessionKey, Timestamp) -> (variant { Ok : SignedDelegation; Err : OpenIdDelegationError }) query;
12321476
1477+ // Email-recovery protocol
1478+ // =======================
1479+ // See `docs/ongoing/email-recovery.md`. Covers both flows:
1480+ // - Setup: prepare_add (authenticated) → smtp_request for
1481+ // register@id.ai → credential bound to the anchor. Removed
1482+ // later via credential_remove.
1483+ // - Recovery: prepare_delegation (anonymous, bound to a
1484+ // session_key) → smtp_request for recover@id.ai → canister
1485+ // stamps a signed delegation seed. The FE then calls
1486+ // email_recovery_get_delegation to retrieve the
1487+ // SignedDelegation.
1488+ // Both flows share the polling status query.
1489+ email_recovery_credential_prepare_add : (IdentityNumber, EmailRecoveryDnsInput) -> (variant { Ok : EmailRecoveryChallenge; Err : EmailRecoveryError });
1490+ email_recovery_prepare_delegation : (EmailRecoveryDnsInput, SessionKey) -> (variant { Ok : EmailRecoveryChallenge; Err : EmailRecoveryError });
1491+ email_recovery_status : (text) -> (EmailRecoveryStatus) query;
1492+ email_recovery_submit_dkim_leaf : (EmailRecoverySubmitDkimLeafArg) -> (variant { Ok : EmailRecoveryStatus; Err : EmailRecoveryError });
1493+ email_recovery_get_delegation : (EmailRecoveryGetDelegationArgs) -> (variant { Ok : SignedDelegation; Err : EmailRecoveryError }) query;
1494+ email_recovery_credential_remove : (IdentityNumber, text) -> (variant { Ok; Err : EmailRecoveryError });
1495+
1496+ // SMTP gateway protocol
1497+ // =====================
1498+ // The off-chain SMTP gateway forwards every inbound message via
1499+ // smtp_request. The canister verifies the email cryptographically
1500+ // and dispatches by recipient: register@id.ai → setup completion,
1501+ // recover@id.ai → recovery delegation stamping. Always returns Ok
1502+ // — the gateway shouldn't get a per-message verification signal
1503+ // back. The FE sees outcomes via the polling status query.
1504+ smtp_request : (SmtpRequest) -> (SmtpResponse);
1505+
1506+ // Called by the gateway at RCPT TO time to decide whether to
1507+ // accept the connection before pulling the message body. Returns
1508+ // Ok for register@id.ai / recover@id.ai (case-insensitive), and
1509+ // 550 (mailbox unavailable) for everything else.
1510+ smtp_request_validate : (SmtpRequest) -> (SmtpResponse) query;
1511+
12331512 // HTTP Gateway protocol
12341513 // =====================
12351514 http_request : (request : HttpRequest) -> (HttpResponse) query;
0 commit comments