diff --git a/.github/workflows/canister-tests.yml b/.github/workflows/canister-tests.yml index bc2596e286..90c904033c 100644 --- a/.github/workflows/canister-tests.yml +++ b/.github/workflows/canister-tests.yml @@ -605,7 +605,9 @@ jobs: - name: Stop dev server if: ${{ always() }} - run: kill ${{ steps.dev-server-start.outputs.dev_server_pid }} + # `|| true` so that a dev server which has already exited (e.g. after + # `icp network stop` triggered shutdown above) doesn't fail the job. + run: kill ${{ steps.dev-server-start.outputs.dev_server_pid }} || true - name: Archive dev server logs if: ${{ always() }} diff --git a/Cargo.lock b/Cargo.lock index 4628bdfeaf..43cebea278 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -952,6 +952,7 @@ dependencies = [ "ff 0.13.1", "generic-array", "group 0.13.0", + "pem-rfc7468", "pkcs8", "rand_core 0.6.4", "sec1", @@ -2177,6 +2178,7 @@ dependencies = [ "lazy_static", "lodepng", "minicbor", + "p256", "pocket-ic", "pretty_assertions", "rand 0.8.5", @@ -2735,6 +2737,18 @@ version = "6.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "pairing" version = "0.22.0" @@ -2952,6 +2966,15 @@ dependencies = [ "yansi", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-error" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 5520dd80c2..8208cadad0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ serde_bytes = "0.11" serde_cbor = "0.11" sha2 = "0.10" rsa = "0.9.7" +p256 = { version = "0.13", features = ["ecdsa", "pkcs8"] } minicbor = "1.0.0" # Certification diff --git a/src/frontend/src/app.html b/src/frontend/src/app.html index b17edcd936..74bff3d2ff 100644 --- a/src/frontend/src/app.html +++ b/src/frontend/src/app.html @@ -3,6 +3,7 @@ + Internet Identity { 'Unauthorized' : IDL.Principal, 'NoSuchCredentials' : IDL.Text, }); + const DkimCheckName = IDL.Variant({ + 'DkimSignaturePresent' : IDL.Null, + 'PublicKeyFetched' : IDL.Null, + 'AlgorithmSupported' : IDL.Null, + 'BodyHashValid' : IDL.Null, + 'SignatureValid' : IDL.Null, + 'SignatureParsed' : IDL.Null, + 'RequiredHeadersSigned' : IDL.Null, + }); + const DkimCheckStatus = IDL.Variant({ + 'Skipped' : IDL.Null, + 'Fail' : IDL.Null, + 'Pass' : IDL.Null, + }); + const DkimCheck = IDL.Record({ + 'status' : DkimCheckStatus, + 'name' : DkimCheckName, + 'detail' : IDL.Opt(IDL.Text), + }); + const DkimVerificationStatus = IDL.Variant({ + 'Unverified' : IDL.Record({ 'checks' : IDL.Vec(DkimCheck) }), + 'Verified' : IDL.Record({ 'checks' : IDL.Vec(DkimCheck) }), + 'Pending' : IDL.Null, + }); + const PostboxEmail = IDL.Record({ + 'dkim_status' : IDL.Opt(DkimVerificationStatus), + 'subject' : IDL.Text, + 'body' : IDL.Text, + 'recipient' : IDL.Text, + 'sender' : IDL.Text, + }); + const PushSubscription = IDL.Record({ + 'endpoint' : IDL.Text, + 'p256dh' : IDL.Vec(IDL.Nat8), + 'auth' : IDL.Vec(IDL.Nat8), + }); + const PushSubscribeError = IDL.Variant({ + 'InvalidSubscription' : IDL.Text, + 'TooManySubscriptions' : IDL.Null, + 'Unauthorized' : IDL.Principal, + 'InternalCanisterError' : IDL.Text, + }); + const PushUnsubscribeError = IDL.Variant({ + 'Unauthorized' : IDL.Principal, + 'InternalCanisterError' : IDL.Text, + }); const HeaderField = IDL.Tuple(IDL.Text, IDL.Text); const HttpRequest = IDL.Record({ 'url' : IDL.Text, @@ -587,6 +633,26 @@ export const idlFactory = ({ IDL }) => { 'anchor_number' : UserNumber, }), }); + const SmtpAddress = IDL.Record({ 'domain' : IDL.Text, 'user' : IDL.Text }); + const SmtpEnvelope = IDL.Record({ 'to' : SmtpAddress, 'from' : SmtpAddress }); + const SmtpHeader = IDL.Record({ 'value' : IDL.Text, 'name' : IDL.Text }); + const SmtpMessage = IDL.Record({ + 'body' : IDL.Vec(IDL.Nat8), + 'headers' : IDL.Vec(SmtpHeader), + }); + const SmtpRequest = IDL.Record({ + 'envelope' : IDL.Opt(SmtpEnvelope), + 'message' : IDL.Opt(SmtpMessage), + 'gateway_flags' : IDL.Opt(IDL.Vec(IDL.Text)), + }); + const SmtpRequestError = IDL.Record({ + 'code' : IDL.Nat64, + 'message' : IDL.Text, + }); + const SmtpResponse = IDL.Variant({ + 'Ok' : IDL.Record({}), + 'Err' : SmtpRequestError, + }); const ArchiveInfo = IDL.Record({ 'archive_config' : IDL.Opt(ArchiveConfig), 'archive_canister' : IDL.Opt(IDL.Principal), @@ -802,6 +868,27 @@ export const idlFactory = ({ IDL }) => { [IDL.Variant({ 'Ok' : IdAliasCredentials, 'Err' : GetIdAliasError })], ['query'], ), + 'get_postbox' : IDL.Func([UserNumber], [IDL.Vec(PostboxEmail)], ['query']), + 'push_subscribe' : IDL.Func( + [UserNumber, PushSubscription], + [IDL.Variant({ 'Ok' : IDL.Null, 'Err' : PushSubscribeError })], + [], + ), + 'push_unsubscribe' : IDL.Func( + [UserNumber, IDL.Text], + [IDL.Variant({ 'Ok' : IDL.Null, 'Err' : PushUnsubscribeError })], + [], + ), + 'push_vapid_public_key' : IDL.Func( + [], + [IDL.Opt(IDL.Vec(IDL.Nat8))], + ['query'], + ), + 'push_init_vapid_key' : IDL.Func( + [], + [IDL.Variant({ 'Ok' : IDL.Vec(IDL.Nat8), 'Err' : PushSubscribeError })], + [], + ), 'get_principal' : IDL.Func( [UserNumber, FrontendHostname], [IDL.Principal], @@ -968,6 +1055,12 @@ export const idlFactory = ({ IDL }) => { [IDL.Variant({ 'Ok' : AccountInfo, 'Err' : SetDefaultAccountError })], [], ), + 'smtp_request' : IDL.Func([SmtpRequest], [SmtpResponse], []), + 'smtp_request_validate' : IDL.Func( + [SmtpRequest], + [SmtpResponse], + ['query'], + ), 'stats' : IDL.Func([], [InternetIdentityStats], ['query']), 'update' : IDL.Func([UserNumber, DeviceKey, DeviceData], [], []), 'update_account' : IDL.Func( diff --git a/src/frontend/src/lib/generated/internet_identity_types.d.ts b/src/frontend/src/lib/generated/internet_identity_types.d.ts index bc8525e3e8..5510599028 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -472,6 +472,26 @@ export interface DeviceWithUsage { * discovery at openid_configuration for { issuer, jwks_uri }. */ export interface DiscoverableOidcConfig { 'discovery_domain' : string } +export interface DkimCheck { + 'status' : DkimCheckStatus, + 'name' : DkimCheckName, + 'detail' : [] | [string], +} +export type DkimCheckName = { 'DkimSignaturePresent' : null } | + { 'PublicKeyFetched' : null } | + { 'AlgorithmSupported' : null } | + { 'BodyHashValid' : null } | + { 'SignatureValid' : null } | + { 'SignatureParsed' : null } | + { 'RequiredHeadersSigned' : null }; +export type DkimCheckStatus = { 'Skipped' : null } | + { 'Fail' : null } | + { 'Pass' : null }; +export type DkimVerificationStatus = { + 'Unverified' : { 'checks' : Array } + } | + { 'Verified' : { 'checks' : Array } } | + { 'Pending' : null }; export interface DummyAuthConfig { /** * Prompts user for a index value (0 - 255) when set to true, @@ -992,6 +1012,24 @@ export interface OpenIdPrepareDelegationResponse { 'expiration' : Timestamp, 'anchor_number' : UserNumber, } +export interface PostboxEmail { + 'dkim_status' : [] | [DkimVerificationStatus], + 'subject' : string, + 'body' : string, + 'recipient' : string, + 'sender' : string, +} +export interface PushSubscription { + 'endpoint' : string, + 'p256dh' : Uint8Array | number[], + 'auth' : Uint8Array | number[], +} +export type PushSubscribeError = { 'InvalidSubscription' : string } | + { 'TooManySubscriptions' : null } | + { 'Unauthorized' : Principal } | + { 'InternalCanisterError' : string }; +export type PushUnsubscribeError = { 'Unauthorized' : Principal } | + { 'InternalCanisterError' : string }; export interface PrepareAccountDelegation { 'user_key' : UserKey, 'expiration' : Timestamp, @@ -1184,6 +1222,25 @@ export interface SignedIdAlias { 'id_alias' : Principal, 'id_dapp' : Principal, } +export interface SmtpAddress { 'domain' : string, 'user' : string } +export interface SmtpEnvelope { 'to' : SmtpAddress, 'from' : SmtpAddress } +/** + * SMTP Gateway Protocol types + * ============================ + */ +export interface SmtpHeader { 'value' : string, 'name' : string } +export interface SmtpMessage { + 'body' : Uint8Array | number[], + 'headers' : Array, +} +export interface SmtpRequest { + 'envelope' : [] | [SmtpEnvelope], + 'message' : [] | [SmtpMessage], + 'gateway_flags' : [] | [Array], +} +export interface SmtpRequestError { 'code' : bigint, 'message' : string } +export type SmtpResponse = { 'Ok' : {} } | + { 'Err' : SmtpRequestError }; export interface StreamingCallbackHttpResponse { 'token' : [] | [Token], 'body' : Uint8Array | number[], @@ -1431,7 +1488,40 @@ export interface _SERVICE { { 'Ok' : IdAliasCredentials } | { 'Err' : GetIdAliasError } >, + 'get_postbox' : ActorMethod<[UserNumber], Array>, 'get_principal' : ActorMethod<[UserNumber, FrontendHostname], Principal>, + /** + * Subscribes the caller's browser for Web Push notifications on new emails + * arriving at the given identity's postbox. Generates the canister's VAPID + * key pair on first call. + */ + 'push_subscribe' : ActorMethod< + [UserNumber, PushSubscription], + { 'Ok' : null } | + { 'Err' : PushSubscribeError } + >, + /** Removes a previously registered push subscription by endpoint URL. */ + 'push_unsubscribe' : ActorMethod< + [UserNumber, string], + { 'Ok' : null } | + { 'Err' : PushUnsubscribeError } + >, + /** + * Returns the canister's VAPID public key (65-byte uncompressed SEC-1). + * Returns empty if the key hasn't been generated yet — use + * `push_init_vapid_key` to generate it. + */ + 'push_vapid_public_key' : ActorMethod<[], [] | [Uint8Array | number[]]>, + /** + * Ensures the canister has a VAPID key pair and returns the public key. + * The frontend calls this at the start of the opt-in flow to obtain the + * `applicationServerKey` required by `PushManager.subscribe()`. + */ + 'push_init_vapid_key' : ActorMethod< + [], + { 'Ok' : Uint8Array | number[] } | + { 'Err' : PushSubscribeError } + >, /** * HTTP Gateway protocol * ===================== @@ -1618,6 +1708,12 @@ export interface _SERVICE { { 'Ok' : AccountInfo } | { 'Err' : SetDefaultAccountError } >, + /** + * SMTP Gateway Protocol + * ===================== + */ + 'smtp_request' : ActorMethod<[SmtpRequest], SmtpResponse>, + 'smtp_request_validate' : ActorMethod<[SmtpRequest], SmtpResponse>, 'stats' : ActorMethod<[], InternetIdentityStats>, 'update' : ActorMethod<[UserNumber, DeviceKey, DeviceData], undefined>, 'update_account' : ActorMethod< diff --git a/src/frontend/src/lib/locales/de.po b/src/frontend/src/lib/locales/de.po index 2e7f4309cf..7b9e0bf1c3 100644 --- a/src/frontend/src/lib/locales/de.po +++ b/src/frontend/src/lib/locales/de.po @@ -13,6 +13,9 @@ msgstr "" "Language-Team: Marco Walz\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +msgid "(no subject)" +msgstr "" + msgid "{0} logo" msgstr "{0}-Logo" @@ -343,9 +346,15 @@ msgstr "{name} bearbeiten" msgid "Edit account" msgstr "Konto bearbeiten" +msgid "Email notifications" +msgstr "Email notifications" + msgid "Email:" msgstr "E-Mail:" +msgid "Emails received for your identity." +msgstr "Emails received for your identity." + msgid "Enable multiple accounts" msgstr "Mehrere Konten aktivieren" @@ -386,6 +395,21 @@ msgstr "Zum Beispiel meldet sich Nutzer A bei Internet Identity mit seinem Googl msgid "Forget about remembering complicated usernames and passwords. With passkeys, you simply pick your name to log in — quick, safe, and hassle-free." msgstr "Vergessen Sie komplizierte Benutzernamen und Passwörter. Mit Passkeys wählen Sie einfach Ihren Namen zum Einloggen – schnell, sicher und unkompliziert." +msgid "From:" +msgstr "" + +msgid "Get notified on this device when a new email arrives." +msgstr "Get notified on this device when a new email arrives." + +msgid "Notifications failed" +msgstr "Notifications failed" + +msgid "Notifications not enabled" +msgstr "Notifications not enabled" + +msgid "To:" +msgstr "" + msgid "Full control" msgstr "Volle Kontrolle" @@ -674,6 +698,42 @@ msgstr "Name:" msgid "No data is shared with Google, Microsoft or Apple on which applications you log in with Internet Identity." msgstr "Es werden keine Daten an Google, Microsoft oder Apple darüber weitergegeben, bei welchen Anwendungen Sie sich mit Internet Identity anmelden." +msgid "No email selected." +msgstr "" + +msgid "DKIM verified" +msgstr "" + +msgid "Not verified" +msgstr "" + +msgid "Verifying..." +msgstr "" + +msgid "Verification details" +msgstr "" + +msgid "DKIM-Signature header present" +msgstr "" + +msgid "Signature parsed" +msgstr "" + +msgid "Algorithm supported" +msgstr "" + +msgid "Required headers signed" +msgstr "" + +msgid "Body hash valid" +msgstr "" + +msgid "Public key fetched via DNS" +msgstr "" + +msgid "RSA signature valid" +msgstr "" + msgid "No identity found" msgstr "Keine Identität gefunden" @@ -755,6 +815,9 @@ msgstr "Wählen Sie etwas Erkennbares, zum Beispiel Ihren Namen" msgid "Please go back to your <0>existing device and choose <1>Start over to try again." msgstr "Gehen Sie zu Ihrem <0>bestehenden Gerät zurück und wählen Sie <1>Neu starten, um es erneut zu versuchen." +msgid "Postbox" +msgstr "" + msgid "Powered by" msgstr "Unterstützt durch" @@ -1296,6 +1359,9 @@ msgstr "Alles bereit. Ihr Passkey wurde registriert." msgid "You're signing in as <0>{name}." msgstr "Sie melden sich als <0>{name} an." +msgid "Your browser blocked the permission prompt." +msgstr "Your browser blocked the permission prompt." + msgid "Your identity and sign-in methods at a glance." msgstr "Ihre Identität und Anmeldemethoden auf einen Blick." diff --git a/src/frontend/src/lib/locales/en.po b/src/frontend/src/lib/locales/en.po index 2ad341f181..39d7322ee8 100644 --- a/src/frontend/src/lib/locales/en.po +++ b/src/frontend/src/lib/locales/en.po @@ -13,6 +13,9 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" +msgid "(no subject)" +msgstr "(no subject)" + msgid "{0} logo" msgstr "{0} logo" @@ -343,9 +346,15 @@ msgstr "Edit {name}" msgid "Edit account" msgstr "Edit account" +msgid "Email notifications" +msgstr "Email notifications" + msgid "Email:" msgstr "Email:" +msgid "Emails received for your identity." +msgstr "Emails received for your identity." + msgid "Enable multiple accounts" msgstr "Enable multiple accounts" @@ -386,6 +395,21 @@ msgstr "For example, user A logs into Internet Identity with their Google accoun msgid "Forget about remembering complicated usernames and passwords. With passkeys, you simply pick your name to log in — quick, safe, and hassle-free." msgstr "Forget about remembering complicated usernames and passwords. With passkeys, you simply pick your name to log in — quick, safe, and hassle-free." +msgid "From:" +msgstr "From:" + +msgid "Get notified on this device when a new email arrives." +msgstr "Get notified on this device when a new email arrives." + +msgid "Notifications failed" +msgstr "Notifications failed" + +msgid "Notifications not enabled" +msgstr "Notifications not enabled" + +msgid "To:" +msgstr "To:" + msgid "Full control" msgstr "Full control" @@ -674,6 +698,42 @@ msgstr "Name:" msgid "No data is shared with Google, Microsoft or Apple on which applications you log in with Internet Identity." msgstr "No data is shared with Google, Microsoft or Apple on which applications you log in with Internet Identity." +msgid "No email selected." +msgstr "No email selected." + +msgid "DKIM verified" +msgstr "DKIM verified" + +msgid "Not verified" +msgstr "Not verified" + +msgid "Verifying..." +msgstr "Verifying..." + +msgid "Verification details" +msgstr "Verification details" + +msgid "DKIM-Signature header present" +msgstr "DKIM-Signature header present" + +msgid "Signature parsed" +msgstr "Signature parsed" + +msgid "Algorithm supported" +msgstr "Algorithm supported" + +msgid "Required headers signed" +msgstr "Required headers signed" + +msgid "Body hash valid" +msgstr "Body hash valid" + +msgid "Public key fetched via DNS" +msgstr "Public key fetched via DNS" + +msgid "RSA signature valid" +msgstr "RSA signature valid" + msgid "No identity found" msgstr "No identity found" @@ -755,6 +815,9 @@ msgstr "Pick something recognizable, like your name" msgid "Please go back to your <0>existing device and choose <1>Start over to try again." msgstr "Please go back to your <0>existing device and choose <1>Start over to try again." +msgid "Postbox" +msgstr "Postbox" + msgid "Powered by" msgstr "Powered by" @@ -1296,6 +1359,9 @@ msgstr "You're all set. Your passkey has been registered." msgid "You're signing in as <0>{name}." msgstr "You're signing in as <0>{name}." +msgid "Your browser blocked the permission prompt." +msgstr "Your browser blocked the permission prompt." + msgid "Your identity and sign-in methods at a glance." msgstr "Your identity and sign-in methods at a glance." diff --git a/src/frontend/src/lib/locales/es.po b/src/frontend/src/lib/locales/es.po index 6dd77a86cb..e637abaea8 100644 --- a/src/frontend/src/lib/locales/es.po +++ b/src/frontend/src/lib/locales/es.po @@ -13,6 +13,9 @@ msgstr "" "Language-Team: Llorenç Muntaner Perello\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +msgid "(no subject)" +msgstr "" + msgid "{0} logo" msgstr "Logo de {0}" @@ -343,9 +346,15 @@ msgstr "Editar {name}" msgid "Edit account" msgstr "Editar cuenta" +msgid "Email notifications" +msgstr "Email notifications" + msgid "Email:" msgstr "Correo electrónico:" +msgid "Emails received for your identity." +msgstr "Emails received for your identity." + msgid "Enable multiple accounts" msgstr "Activar cuentas múltiples" @@ -386,6 +395,21 @@ msgstr "Por ejemplo, el usuario A inicia sesión en Internet Identity con su cue msgid "Forget about remembering complicated usernames and passwords. With passkeys, you simply pick your name to log in — quick, safe, and hassle-free." msgstr "Olvídate de recordar nombres de usuario y contraseñas complicadas. Con passkeys, simplemente eliges tu nombre para iniciar sesión — rápido, seguro y sin complicaciones." +msgid "From:" +msgstr "" + +msgid "Get notified on this device when a new email arrives." +msgstr "Get notified on this device when a new email arrives." + +msgid "Notifications failed" +msgstr "Notifications failed" + +msgid "Notifications not enabled" +msgstr "Notifications not enabled" + +msgid "To:" +msgstr "" + msgid "Full control" msgstr "Control total" @@ -674,6 +698,42 @@ msgstr "Nombre:" msgid "No data is shared with Google, Microsoft or Apple on which applications you log in with Internet Identity." msgstr "No se comparten datos con Google, Microsoft o Apple sobre en qué aplicaciones inicias sesión con Internet Identity." +msgid "No email selected." +msgstr "" + +msgid "DKIM verified" +msgstr "" + +msgid "Not verified" +msgstr "" + +msgid "Verifying..." +msgstr "" + +msgid "Verification details" +msgstr "" + +msgid "DKIM-Signature header present" +msgstr "" + +msgid "Signature parsed" +msgstr "" + +msgid "Algorithm supported" +msgstr "" + +msgid "Required headers signed" +msgstr "" + +msgid "Body hash valid" +msgstr "" + +msgid "Public key fetched via DNS" +msgstr "" + +msgid "RSA signature valid" +msgstr "" + msgid "No identity found" msgstr "No se encontró ninguna identidad" @@ -755,6 +815,9 @@ msgstr "Elija algo que puedas reconocer fácilmente, como tu nombre" msgid "Please go back to your <0>existing device and choose <1>Start over to try again." msgstr "Vuelve a tu <0>dispositivo existente y elige <1>Empezar de nuevo para volver a intentarlo." +msgid "Postbox" +msgstr "" + msgid "Powered by" msgstr "Desarrollado por" @@ -1296,6 +1359,9 @@ msgstr "Todo listo. Tu passkey ha sido registrada." msgid "You're signing in as <0>{name}." msgstr "Estás iniciando sesión como <0>{name}." +msgid "Your browser blocked the permission prompt." +msgstr "Your browser blocked the permission prompt." + msgid "Your identity and sign-in methods at a glance." msgstr "Tu identidad y métodos de inicio de sesión de un vistazo." diff --git a/src/frontend/src/lib/locales/fr.po b/src/frontend/src/lib/locales/fr.po index a7b7fdb8a7..f68be9fa47 100644 --- a/src/frontend/src/lib/locales/fr.po +++ b/src/frontend/src/lib/locales/fr.po @@ -13,6 +13,9 @@ msgstr "" "Language-Team: Mathieu Ducroux, Nicolas Mattia\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" +msgid "(no subject)" +msgstr "" + msgid "{0} logo" msgstr "Logo {0}" @@ -343,9 +346,15 @@ msgstr "Modifier {name}" msgid "Edit account" msgstr "Modifier le compte" +msgid "Email notifications" +msgstr "Email notifications" + msgid "Email:" msgstr "Adresse e-mail :" +msgid "Emails received for your identity." +msgstr "Emails received for your identity." + msgid "Enable multiple accounts" msgstr "Activer plusieurs comptes" @@ -386,6 +395,21 @@ msgstr "Par exemple, l’utilisateur A se connecte à Internet Identity avec son msgid "Forget about remembering complicated usernames and passwords. With passkeys, you simply pick your name to log in — quick, safe, and hassle-free." msgstr "Oubliez les noms d’utilisateur et mots de passe compliqués. Avec les passkeys, il vous suffit de choisir votre nom pour vous connecter: rapide, sûr et sans effort." +msgid "From:" +msgstr "" + +msgid "Get notified on this device when a new email arrives." +msgstr "Get notified on this device when a new email arrives." + +msgid "Notifications failed" +msgstr "Notifications failed" + +msgid "Notifications not enabled" +msgstr "Notifications not enabled" + +msgid "To:" +msgstr "" + msgid "Full control" msgstr "Contrôle total" @@ -674,6 +698,42 @@ msgstr "Nom :" msgid "No data is shared with Google, Microsoft or Apple on which applications you log in with Internet Identity." msgstr "Aucune donnée n’est partagée avec Google, Microsoft ou Apple concernant les applications auxquelles vous vous connectez avec Internet Identity." +msgid "No email selected." +msgstr "" + +msgid "DKIM verified" +msgstr "" + +msgid "Not verified" +msgstr "" + +msgid "Verifying..." +msgstr "" + +msgid "Verification details" +msgstr "" + +msgid "DKIM-Signature header present" +msgstr "" + +msgid "Signature parsed" +msgstr "" + +msgid "Algorithm supported" +msgstr "" + +msgid "Required headers signed" +msgstr "" + +msgid "Body hash valid" +msgstr "" + +msgid "Public key fetched via DNS" +msgstr "" + +msgid "RSA signature valid" +msgstr "" + msgid "No identity found" msgstr "Aucune identité trouvée" @@ -755,6 +815,9 @@ msgstr "Choisissez quelque chose de reconnaissable, comme votre nom" msgid "Please go back to your <0>existing device and choose <1>Start over to try again." msgstr "Veuillez retourner sur votre <0>appareil existant et sélectionner <1>Recommencer pour réessayer." +msgid "Postbox" +msgstr "" + msgid "Powered by" msgstr "Propulsé par" @@ -1296,6 +1359,9 @@ msgstr "Tout est prêt. Votre passkey a été enregistrée." msgid "You're signing in as <0>{name}." msgstr "Vous vous connectez en tant que <0>{name}." +msgid "Your browser blocked the permission prompt." +msgstr "Your browser blocked the permission prompt." + msgid "Your identity and sign-in methods at a glance." msgstr "Votre identité et vos méthodes de connexion en un coup d’œil." diff --git a/src/frontend/src/lib/locales/id.po b/src/frontend/src/lib/locales/id.po index 03c2c90f99..635f9f37d5 100644 --- a/src/frontend/src/lib/locales/id.po +++ b/src/frontend/src/lib/locales/id.po @@ -13,6 +13,9 @@ msgstr "" "Language-Team: \n" "Plural-Forms: nplurals=1; plural=0;\n" +msgid "(no subject)" +msgstr "" + msgid "{0} logo" msgstr "Logo {0}" @@ -343,9 +346,15 @@ msgstr "Edit {name}" msgid "Edit account" msgstr "Edit akun" +msgid "Email notifications" +msgstr "Email notifications" + msgid "Email:" msgstr "Email:" +msgid "Emails received for your identity." +msgstr "Emails received for your identity." + msgid "Enable multiple accounts" msgstr "Aktifkan banyak akun" @@ -386,6 +395,21 @@ msgstr "Misalnya, pengguna A masuk ke Internet Identity dengan akun Google merek msgid "Forget about remembering complicated usernames and passwords. With passkeys, you simply pick your name to log in — quick, safe, and hassle-free." msgstr "Lupakan tentang mengingat nama pengguna dan kata sandi yang rumit. Dengan passkey, Anda cukup memilih nama Anda untuk masuk — cepat, aman, dan mudah." +msgid "From:" +msgstr "" + +msgid "Get notified on this device when a new email arrives." +msgstr "Get notified on this device when a new email arrives." + +msgid "Notifications failed" +msgstr "Notifications failed" + +msgid "Notifications not enabled" +msgstr "Notifications not enabled" + +msgid "To:" +msgstr "" + msgid "Full control" msgstr "Kontrol penuh" @@ -674,6 +698,42 @@ msgstr "Nama:" msgid "No data is shared with Google, Microsoft or Apple on which applications you log in with Internet Identity." msgstr "Tidak ada data yang dibagikan kepada Google, Microsoft, atau Apple mengenai aplikasi mana yang Anda masuk dengan Internet Identity." +msgid "No email selected." +msgstr "" + +msgid "DKIM verified" +msgstr "" + +msgid "Not verified" +msgstr "" + +msgid "Verifying..." +msgstr "" + +msgid "Verification details" +msgstr "" + +msgid "DKIM-Signature header present" +msgstr "" + +msgid "Signature parsed" +msgstr "" + +msgid "Algorithm supported" +msgstr "" + +msgid "Required headers signed" +msgstr "" + +msgid "Body hash valid" +msgstr "" + +msgid "Public key fetched via DNS" +msgstr "" + +msgid "RSA signature valid" +msgstr "" + msgid "No identity found" msgstr "Tidak ada identitas yang ditemukan" @@ -755,6 +815,9 @@ msgstr "Pilih sesuatu yang mudah dikenali, seperti nama Anda" msgid "Please go back to your <0>existing device and choose <1>Start over to try again." msgstr "Silakan kembali ke <0>perangkat Anda yang ada dan pilih <1>Mulai ulang untuk mencoba lagi." +msgid "Postbox" +msgstr "" + msgid "Powered by" msgstr "Didukung oleh" @@ -1296,6 +1359,9 @@ msgstr "Semua siap. Passkey Anda telah terdaftar." msgid "You're signing in as <0>{name}." msgstr "Anda masuk sebagai <0>{name}." +msgid "Your browser blocked the permission prompt." +msgstr "Your browser blocked the permission prompt." + msgid "Your identity and sign-in methods at a glance." msgstr "Identitas dan metode masuk Anda sekilas." diff --git a/src/frontend/src/lib/locales/it.po b/src/frontend/src/lib/locales/it.po index ccd31dbaf1..80eea4ff59 100644 --- a/src/frontend/src/lib/locales/it.po +++ b/src/frontend/src/lib/locales/it.po @@ -13,6 +13,9 @@ msgstr "" "Language-Team: Francesco Rizzi, Antonio Ventilii\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +msgid "(no subject)" +msgstr "" + msgid "{0} logo" msgstr "Logo {0}" @@ -343,9 +346,15 @@ msgstr "Modifica {name}" msgid "Edit account" msgstr "Modifica account" +msgid "Email notifications" +msgstr "Email notifications" + msgid "Email:" msgstr "Email:" +msgid "Emails received for your identity." +msgstr "Emails received for your identity." + msgid "Enable multiple accounts" msgstr "Abilita account multipli" @@ -386,6 +395,21 @@ msgstr "Ad esempio, l'utente A accede a Internet Identity con il proprio account msgid "Forget about remembering complicated usernames and passwords. With passkeys, you simply pick your name to log in — quick, safe, and hassle-free." msgstr "Dimentica di dover ricordare nomi utente e password complicati. Con le passkey, ti basta scegliere il tuo nome per accedere: veloce, sicuro e senza problemi." +msgid "From:" +msgstr "" + +msgid "Get notified on this device when a new email arrives." +msgstr "Get notified on this device when a new email arrives." + +msgid "Notifications failed" +msgstr "Notifications failed" + +msgid "Notifications not enabled" +msgstr "Notifications not enabled" + +msgid "To:" +msgstr "" + msgid "Full control" msgstr "Controllo totale" @@ -674,6 +698,42 @@ msgstr "Nome:" msgid "No data is shared with Google, Microsoft or Apple on which applications you log in with Internet Identity." msgstr "Nessun dato relativo alle applicazioni a cui accedi con Internet Identity viene condiviso con Google, Microsoft o Apple." +msgid "No email selected." +msgstr "" + +msgid "DKIM verified" +msgstr "" + +msgid "Not verified" +msgstr "" + +msgid "Verifying..." +msgstr "" + +msgid "Verification details" +msgstr "" + +msgid "DKIM-Signature header present" +msgstr "" + +msgid "Signature parsed" +msgstr "" + +msgid "Algorithm supported" +msgstr "" + +msgid "Required headers signed" +msgstr "" + +msgid "Body hash valid" +msgstr "" + +msgid "Public key fetched via DNS" +msgstr "" + +msgid "RSA signature valid" +msgstr "" + msgid "No identity found" msgstr "Nessuna identità trovata" @@ -755,6 +815,9 @@ msgstr "Scegli qualcosa di riconoscibile, come il tuo nome" msgid "Please go back to your <0>existing device and choose <1>Start over to try again." msgstr "Torna al tuo <0>dispositivo esistente e scegli <1>Inizia da capo per riprovare." +msgid "Postbox" +msgstr "" + msgid "Powered by" msgstr "Basato su" @@ -1296,6 +1359,9 @@ msgstr "Hai finito. La tua passkey è stata registrata." msgid "You're signing in as <0>{name}." msgstr "Stai accedendo come <0>{name}." +msgid "Your browser blocked the permission prompt." +msgstr "Your browser blocked the permission prompt." + msgid "Your identity and sign-in methods at a glance." msgstr "La tua identità e i metodi di accesso a colpo d'occhio." diff --git a/src/frontend/src/lib/locales/nl.po b/src/frontend/src/lib/locales/nl.po index 9f5beed574..78004d1b83 100644 --- a/src/frontend/src/lib/locales/nl.po +++ b/src/frontend/src/lib/locales/nl.po @@ -13,6 +13,9 @@ msgstr "" "Language-Team: Thomas Gladdines\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +msgid "(no subject)" +msgstr "" + msgid "{0} logo" msgstr "{0} logo" @@ -343,9 +346,15 @@ msgstr "Bewerk {name}" msgid "Edit account" msgstr "Account bewerken" +msgid "Email notifications" +msgstr "Email notifications" + msgid "Email:" msgstr "E-mailadres:" +msgid "Emails received for your identity." +msgstr "Emails received for your identity." + msgid "Enable multiple accounts" msgstr "Meerdere accounts inschakelen" @@ -386,6 +395,21 @@ msgstr "Voorbeeld: gebruiker A logt in op Internet Identity met hun Google-accou msgid "Forget about remembering complicated usernames and passwords. With passkeys, you simply pick your name to log in — quick, safe, and hassle-free." msgstr "Geen gebruikersnamen of wachtwoorden meer. Met discoverable passkeys logt u in door simpelweg uw naam te kiezen." +msgid "From:" +msgstr "" + +msgid "Get notified on this device when a new email arrives." +msgstr "Get notified on this device when a new email arrives." + +msgid "Notifications failed" +msgstr "Notifications failed" + +msgid "Notifications not enabled" +msgstr "Notifications not enabled" + +msgid "To:" +msgstr "" + msgid "Full control" msgstr "Volledige controle" @@ -674,6 +698,42 @@ msgstr "Naam:" msgid "No data is shared with Google, Microsoft or Apple on which applications you log in with Internet Identity." msgstr "Er worden geen gegevens gedeeld met Google, Microsoft of Apple over op welke applicaties u inlogt met Internet Identity." +msgid "No email selected." +msgstr "" + +msgid "DKIM verified" +msgstr "" + +msgid "Not verified" +msgstr "" + +msgid "Verifying..." +msgstr "" + +msgid "Verification details" +msgstr "" + +msgid "DKIM-Signature header present" +msgstr "" + +msgid "Signature parsed" +msgstr "" + +msgid "Algorithm supported" +msgstr "" + +msgid "Required headers signed" +msgstr "" + +msgid "Body hash valid" +msgstr "" + +msgid "Public key fetched via DNS" +msgstr "" + +msgid "RSA signature valid" +msgstr "" + msgid "No identity found" msgstr "Geen identiteit gevonden" @@ -755,6 +815,9 @@ msgstr "Kies iets herkenbaars, zoals uw naam" msgid "Please go back to your <0>existing device and choose <1>Start over to try again." msgstr "Ga terug naar uw <0>bestaande apparaat en kies <1>Opnieuw beginnen om het opnieuw te proberen." +msgid "Postbox" +msgstr "" + msgid "Powered by" msgstr "Aangedreven door" @@ -1296,6 +1359,9 @@ msgstr "U bent klaar. Uw passkey is geregistreerd." msgid "You're signing in as <0>{name}." msgstr "U logt in als <0>{name}." +msgid "Your browser blocked the permission prompt." +msgstr "Your browser blocked the permission prompt." + msgid "Your identity and sign-in methods at a glance." msgstr "Uw identiteit en inlogmethoden in één oogopslag." diff --git a/src/frontend/src/lib/locales/pl.po b/src/frontend/src/lib/locales/pl.po index 3d1bc59382..03da96fdc5 100644 --- a/src/frontend/src/lib/locales/pl.po +++ b/src/frontend/src/lib/locales/pl.po @@ -13,6 +13,9 @@ msgstr "" "Language-Team: \n" "Plural-Forms: nplurals=3; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2));\n" +msgid "(no subject)" +msgstr "" + msgid "{0} logo" msgstr "{0} logo" @@ -343,9 +346,15 @@ msgstr "Edytuj {name}" msgid "Edit account" msgstr "Edytuj konto" +msgid "Email notifications" +msgstr "Email notifications" + msgid "Email:" msgstr "Adres e-mail:" +msgid "Emails received for your identity." +msgstr "Emails received for your identity." + msgid "Enable multiple accounts" msgstr "Włącz wiele kont" @@ -386,6 +395,21 @@ msgstr "Na przykład użytkownik A loguje się do Internet Identity za pomocą s msgid "Forget about remembering complicated usernames and passwords. With passkeys, you simply pick your name to log in — quick, safe, and hassle-free." msgstr "Zapomnij o zapamiętywaniu skomplikowanych nazw użytkowników i haseł. Dzięki kluczom dostępu po prostu wybierasz swoją nazwę, aby się zalogować — szybko, bezpiecznie i bezproblemowo." +msgid "From:" +msgstr "" + +msgid "Get notified on this device when a new email arrives." +msgstr "Get notified on this device when a new email arrives." + +msgid "Notifications failed" +msgstr "Notifications failed" + +msgid "Notifications not enabled" +msgstr "Notifications not enabled" + +msgid "To:" +msgstr "" + msgid "Full control" msgstr "Pełna kontrola" @@ -674,6 +698,42 @@ msgstr "Nazwa:" msgid "No data is shared with Google, Microsoft or Apple on which applications you log in with Internet Identity." msgstr "Nie są udostępniane żadne dane Google, Microsoft ani Apple dotyczące aplikacji, w których logujesz się za pomocą Internet Identity." +msgid "No email selected." +msgstr "" + +msgid "DKIM verified" +msgstr "" + +msgid "Not verified" +msgstr "" + +msgid "Verifying..." +msgstr "" + +msgid "Verification details" +msgstr "" + +msgid "DKIM-Signature header present" +msgstr "" + +msgid "Signature parsed" +msgstr "" + +msgid "Algorithm supported" +msgstr "" + +msgid "Required headers signed" +msgstr "" + +msgid "Body hash valid" +msgstr "" + +msgid "Public key fetched via DNS" +msgstr "" + +msgid "RSA signature valid" +msgstr "" + msgid "No identity found" msgstr "Nie znaleziono tożsamości" @@ -755,6 +815,9 @@ msgstr "Wybierz coś rozpoznawalnego, na przykład swoje imię" msgid "Please go back to your <0>existing device and choose <1>Start over to try again." msgstr "Wróć do swojego <0>istniejącego urządzenia i wybierz <1>Zacznij od nowa, aby spróbować ponownie." +msgid "Postbox" +msgstr "" + msgid "Powered by" msgstr "Zasilane przez" @@ -1296,6 +1359,9 @@ msgstr "Wszystko gotowe. Twój klucz dostępu został zarejestrowany." msgid "You're signing in as <0>{name}." msgstr "Logujesz się jako <0>{name}." +msgid "Your browser blocked the permission prompt." +msgstr "Your browser blocked the permission prompt." + msgid "Your identity and sign-in methods at a glance." msgstr "Twoja tożsamość i metody logowania w skrócie." diff --git a/src/frontend/src/lib/locales/ru.po b/src/frontend/src/lib/locales/ru.po index 1a19f2a4a7..e4d38b5456 100644 --- a/src/frontend/src/lib/locales/ru.po +++ b/src/frontend/src/lib/locales/ru.po @@ -13,6 +13,9 @@ msgstr "" "Language-Team: Аршавир Тер-Габриелян\n" "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" +msgid "(no subject)" +msgstr "" + msgid "{0} logo" msgstr "{0} логотип" @@ -343,9 +346,15 @@ msgstr "Редактировать {name}" msgid "Edit account" msgstr "Редактировать аккаунт" +msgid "Email notifications" +msgstr "Email notifications" + msgid "Email:" msgstr "Email:" +msgid "Emails received for your identity." +msgstr "Emails received for your identity." + msgid "Enable multiple accounts" msgstr "Включить несколько аккаунтов" @@ -386,6 +395,21 @@ msgstr "Например, пользователь А входит в Internet I msgid "Forget about remembering complicated usernames and passwords. With passkeys, you simply pick your name to log in — quick, safe, and hassle-free." msgstr "Не нужно придумывать и запоминать логины и пароли. Используйте ключ доступа — это быстрее и безопаснее." +msgid "From:" +msgstr "" + +msgid "Get notified on this device when a new email arrives." +msgstr "Get notified on this device when a new email arrives." + +msgid "Notifications failed" +msgstr "Notifications failed" + +msgid "Notifications not enabled" +msgstr "Notifications not enabled" + +msgid "To:" +msgstr "" + msgid "Full control" msgstr "Полный контроль" @@ -674,6 +698,42 @@ msgstr "Имя:" msgid "No data is shared with Google, Microsoft or Apple on which applications you log in with Internet Identity." msgstr "Никакие данные о том, в какие приложения вы входите с помощью Internet Identity, не передаются Google, Microsoft или Apple." +msgid "No email selected." +msgstr "" + +msgid "DKIM verified" +msgstr "" + +msgid "Not verified" +msgstr "" + +msgid "Verifying..." +msgstr "" + +msgid "Verification details" +msgstr "" + +msgid "DKIM-Signature header present" +msgstr "" + +msgid "Signature parsed" +msgstr "" + +msgid "Algorithm supported" +msgstr "" + +msgid "Required headers signed" +msgstr "" + +msgid "Body hash valid" +msgstr "" + +msgid "Public key fetched via DNS" +msgstr "" + +msgid "RSA signature valid" +msgstr "" + msgid "No identity found" msgstr "Учётная запись не найдена" @@ -755,6 +815,9 @@ msgstr "Выберите что-то узнаваемое, например, в msgid "Please go back to your <0>existing device and choose <1>Start over to try again." msgstr "Пожалуйста, вернитесь на ваше <0>существующее устройство и выберите <1>Начать заново, чтобы повторить попытку." +msgid "Postbox" +msgstr "" + msgid "Powered by" msgstr "Разработано" @@ -1296,6 +1359,9 @@ msgstr "Все готово. Ваш ключ доступа зарегистри msgid "You're signing in as <0>{name}." msgstr "Вы входите как <0>{name}." +msgid "Your browser blocked the permission prompt." +msgstr "Your browser blocked the permission prompt." + msgid "Your identity and sign-in methods at a glance." msgstr "Ваша учётная запись и способы входа — всё в одном месте." diff --git a/src/frontend/src/lib/locales/uk.po b/src/frontend/src/lib/locales/uk.po index 4414a78258..060c7d9b1f 100644 --- a/src/frontend/src/lib/locales/uk.po +++ b/src/frontend/src/lib/locales/uk.po @@ -13,6 +13,9 @@ msgstr "" "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" "X-Generator: Poedit 3.9\n" +msgid "(no subject)" +msgstr "" + msgid "{0} logo" msgstr "{0} логотип" @@ -343,9 +346,15 @@ msgstr "Редагувати {name}" msgid "Edit account" msgstr "Редагувати обліковий запис" +msgid "Email notifications" +msgstr "Email notifications" + msgid "Email:" msgstr "Електронна пошта:" +msgid "Emails received for your identity." +msgstr "Emails received for your identity." + msgid "Enable multiple accounts" msgstr "Увімкнути кілька облікових записів" @@ -386,6 +395,21 @@ msgstr "Наприклад, користувач A входить до Internet msgid "Forget about remembering complicated usernames and passwords. With passkeys, you simply pick your name to log in — quick, safe, and hassle-free." msgstr "Забудьте про запам'ятовування складних імен користувача та паролів. Завдяки ключам доступу ви просто обираєте своє ім'я для входу — швидко, безпечно та без проблем." +msgid "From:" +msgstr "" + +msgid "Get notified on this device when a new email arrives." +msgstr "Get notified on this device when a new email arrives." + +msgid "Notifications failed" +msgstr "Notifications failed" + +msgid "Notifications not enabled" +msgstr "Notifications not enabled" + +msgid "To:" +msgstr "" + msgid "Full control" msgstr "Повний контроль" @@ -674,6 +698,42 @@ msgstr "Ім'я:" msgid "No data is shared with Google, Microsoft or Apple on which applications you log in with Internet Identity." msgstr "Жодні дані про те, в які застосунки ви входите через Internet Identity, не передаються Google, Microsoft чи Apple." +msgid "No email selected." +msgstr "" + +msgid "DKIM verified" +msgstr "" + +msgid "Not verified" +msgstr "" + +msgid "Verifying..." +msgstr "" + +msgid "Verification details" +msgstr "" + +msgid "DKIM-Signature header present" +msgstr "" + +msgid "Signature parsed" +msgstr "" + +msgid "Algorithm supported" +msgstr "" + +msgid "Required headers signed" +msgstr "" + +msgid "Body hash valid" +msgstr "" + +msgid "Public key fetched via DNS" +msgstr "" + +msgid "RSA signature valid" +msgstr "" + msgid "No identity found" msgstr "Ідентифікатор не знайдено" @@ -755,6 +815,9 @@ msgstr "Виберіть щось впізнаване, наприклад, св msgid "Please go back to your <0>existing device and choose <1>Start over to try again." msgstr "Будь ласка, поверніться до свого <0>наявного пристрою та виберіть <1>Почати спочатку, щоб спробувати знову." +msgid "Postbox" +msgstr "" + msgid "Powered by" msgstr "Працює на" @@ -1296,6 +1359,9 @@ msgstr "Усе готово. Ваш ключ доступу зареєстров msgid "You're signing in as <0>{name}." msgstr "Ви входите як <0>{name}." +msgid "Your browser blocked the permission prompt." +msgstr "Your browser blocked the permission prompt." + msgid "Your identity and sign-in methods at a glance." msgstr "Ваш ідентифікатор та способи входу — все в одному місці." diff --git a/src/frontend/src/lib/locales/ur.po b/src/frontend/src/lib/locales/ur.po index 468e9e4959..a582a89284 100644 --- a/src/frontend/src/lib/locales/ur.po +++ b/src/frontend/src/lib/locales/ur.po @@ -13,6 +13,9 @@ msgstr "" "Language-Team: Muhammad Abdullah, Muhammad Umar\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +msgid "(no subject)" +msgstr "" + msgid "{0} logo" msgstr "{0} لوگو" @@ -343,9 +346,15 @@ msgstr "{name} میں ترمیم کریں" msgid "Edit account" msgstr "اکاؤنٹ میں ترمیم کریں" +msgid "Email notifications" +msgstr "Email notifications" + msgid "Email:" msgstr "ای میل:" +msgid "Emails received for your identity." +msgstr "Emails received for your identity." + msgid "Enable multiple accounts" msgstr "متعدد اکاؤنٹس فعال کریں" @@ -386,6 +395,21 @@ msgstr "مثال کے طور پر، صارف A اپنی Google اکاؤنٹ “A msgid "Forget about remembering complicated usernames and passwords. With passkeys, you simply pick your name to log in — quick, safe, and hassle-free." msgstr "مشکل یوزرنیم اور پاس ورڈ یاد رکھنے کی فکر چھوڑ دیں۔ پاس کیز کے ساتھ آپ بس اپنا نام منتخب کر کے لاگ اِن کرتے ہیں — تیز، محفوظ اور بے جھنجھٹ۔" +msgid "From:" +msgstr "" + +msgid "Get notified on this device when a new email arrives." +msgstr "Get notified on this device when a new email arrives." + +msgid "Notifications failed" +msgstr "Notifications failed" + +msgid "Notifications not enabled" +msgstr "Notifications not enabled" + +msgid "To:" +msgstr "" + msgid "Full control" msgstr "مکمل کنٹرول" @@ -674,6 +698,42 @@ msgstr "نام:" msgid "No data is shared with Google, Microsoft or Apple on which applications you log in with Internet Identity." msgstr "Google، Microsoft یا Apple کے ساتھ یہ معلومات شیئر نہیں کی جاتیں کہ آپ Internet Identity کے ذریعے کن ایپلیکیشنز میں لاگ اِن کرتے ہیں۔" +msgid "No email selected." +msgstr "" + +msgid "DKIM verified" +msgstr "" + +msgid "Not verified" +msgstr "" + +msgid "Verifying..." +msgstr "" + +msgid "Verification details" +msgstr "" + +msgid "DKIM-Signature header present" +msgstr "" + +msgid "Signature parsed" +msgstr "" + +msgid "Algorithm supported" +msgstr "" + +msgid "Required headers signed" +msgstr "" + +msgid "Body hash valid" +msgstr "" + +msgid "Public key fetched via DNS" +msgstr "" + +msgid "RSA signature valid" +msgstr "" + msgid "No identity found" msgstr "کوئی شناخت نہیں ملی" @@ -755,6 +815,9 @@ msgstr "ایسی چیز منتخب کریں جسے پہچانا جا سکے، ج msgid "Please go back to your <0>existing device and choose <1>Start over to try again." msgstr "براہِ کرم اپنے <0>موجودہ ڈیوائس پر واپس جائیں اور دوبارہ کوشش کرنے کے لیے <1>دوبارہ شروع کریں منتخب کریں۔" +msgid "Postbox" +msgstr "" + msgid "Powered by" msgstr "بذریعہ" @@ -1296,6 +1359,9 @@ msgstr "سب تیار ہے۔ آپ کی پاس کی رجسٹر ہو گئی ہے۔ msgid "You're signing in as <0>{name}." msgstr "آپ بطور <0>{name} سائن اِن کر رہے ہیں۔" +msgid "Your browser blocked the permission prompt." +msgstr "Your browser blocked the permission prompt." + msgid "Your identity and sign-in methods at a glance." msgstr "آپ کی شناخت اور سائن اِن کے طریقے ایک نظر میں۔" diff --git a/src/frontend/src/lib/utils/pushNotifications.ts b/src/frontend/src/lib/utils/pushNotifications.ts new file mode 100644 index 0000000000..fccc9eb97a --- /dev/null +++ b/src/frontend/src/lib/utils/pushNotifications.ts @@ -0,0 +1,203 @@ +/** + * Web Push notification helpers. + * + * Browser flow on first opt-in (must run inside a user gesture, otherwise + * `Notification.requestPermission()` is rejected by modern browsers): + * + * 1. Register the service worker at `/sw.js`. + * 2. Ask for notification permission. + * 3. Fetch the canister's VAPID public key (triggers canister-side + * generation if this is the first subscriber). + * 4. Call `PushManager.subscribe()` with that key as `applicationServerKey`. + * 5. Forward the resulting `PushSubscription` to the canister so it can POST + * to the endpoint when new emails arrive. + * + * V1 does not use the `p256dh`/`auth` keys for payload encryption — the + * canister sends empty-body pushes. We still send them to the canister so + * they're stored for when we add RFC 8291 encryption later. + */ +import type { ActorSubclass } from "@icp-sdk/core/agent"; +import type { _SERVICE } from "$lib/generated/internet_identity_types"; + +export const isPushSupported = (): boolean => + typeof window !== "undefined" && + "serviceWorker" in navigator && + "PushManager" in window && + "Notification" in window; + +/** Returns the active subscription if one exists for this browser. */ +export const getExistingSubscription = + async (): Promise => { + if (!isPushSupported()) return null; + const registration = await navigator.serviceWorker.getRegistration("/"); + if (!registration) return null; + return registration.pushManager.getSubscription(); + }; + +/** Convenience wrapper for "is this browser currently subscribed?". */ +export const isCurrentlySubscribed = async (): Promise => { + const sub = await getExistingSubscription(); + return sub !== null; +}; + +/** + * PoC side-channel: posts the newest email's sender/subject to the active + * service worker so the next `push` event can show meaningful content + * instead of the generic fallback. Silently no-ops when no service worker + * is registered or active. + * + * Not a long-term solution — the service worker's in-memory cache is wiped + * whenever the worker is terminated (which can happen any time the page is + * closed or idle). The proper fix is RFC 8291 payload encryption in the + * canister so the push itself carries the metadata. Until that ships this + * gives the postbox page a way to keep the SW's cache warm. + */ +export const postLatestEmailToServiceWorker = async (email: { + sender: string; + subject: string; +}): Promise => { + if (!("serviceWorker" in navigator)) return; + const registration = await navigator.serviceWorker.getRegistration("/"); + if (!registration?.active) return; + registration.active.postMessage({ + type: "LATEST_EMAIL", + from: email.sender, + subject: email.subject, + }); +}; + +/** + * Full opt-in flow. Must be called from within a user gesture (e.g. a click + * handler) so the browser allows `Notification.requestPermission()`. + * + * Returns the created subscription on success, or `null` if the user denied + * the permission prompt. Throws for other errors (network, canister). + */ +export const subscribeToPush = async ( + actor: ActorSubclass<_SERVICE>, + identityNumber: bigint, +): Promise => { + if (!isPushSupported()) { + throw new Error("Push notifications are not supported in this browser"); + } + + // 1. Register the service worker (idempotent). + const registration = await navigator.serviceWorker.register("/sw.js", { + scope: "/", + }); + // Wait for it to be active — `pushManager.subscribe` can fail otherwise. + if (registration.installing || registration.waiting) { + await navigator.serviceWorker.ready; + } + + // 2. Ask for notification permission. + const permission = await Notification.requestPermission(); + if (permission !== "granted") { + return null; + } + + // 3. Fetch (or generate) the VAPID public key from the canister. + // `push_init_vapid_key` is an update call that generates the key if + // it doesn't exist yet, so the first subscriber on this canister pays + // an extra raw_rand + state write. Subsequent subscribers get the + // already-stored key immediately. + const initResult = await actor.push_init_vapid_key(); + if ("Err" in initResult) { + throw new Error( + `Failed to initialize VAPID key: ${JSON.stringify(initResult.Err)}`, + ); + } + // Copy into a fresh ArrayBuffer-backed Uint8Array. PushManager.subscribe + // rejects Uint8Array, which the generated TS types + // include as a possibility. + const vapidSrc = + initResult.Ok instanceof Uint8Array + ? initResult.Ok + : new Uint8Array(initResult.Ok); + const vapidKey = new Uint8Array(new ArrayBuffer(vapidSrc.length)); + vapidKey.set(vapidSrc); + + // 4. Subscribe with PushManager. + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: vapidKey, + }); + + // 5. Send the subscription to the canister. + const json = subscription.toJSON(); + if ( + json.endpoint === undefined || + json.keys?.p256dh === undefined || + json.keys?.auth === undefined + ) { + // Revert the local subscription so the user isn't in a weird state. + await subscription.unsubscribe(); + throw new Error("Browser returned an incomplete PushSubscription"); + } + + const result = await actor.push_subscribe(identityNumber, { + endpoint: json.endpoint, + p256dh: base64UrlToBytes(json.keys.p256dh), + auth: base64UrlToBytes(json.keys.auth), + }); + if ("Err" in result) { + await subscription.unsubscribe(); + throw new Error( + `Canister rejected subscription: ${JSON.stringify(result.Err)}`, + ); + } + + return subscription; +}; + +/** + * Removes the current browser's subscription from the canister and from + * the browser's PushManager. Silently no-ops if not subscribed. + */ +export const unsubscribeFromPush = async ( + actor: ActorSubclass<_SERVICE>, + identityNumber: bigint, +): Promise => { + const subscription = await getExistingSubscription(); + if (subscription === null) return; + + // Tell the canister first — if that fails, we still want to unsubscribe + // locally so the user sees the UI flip off. + try { + await actor.push_unsubscribe(identityNumber, subscription.endpoint); + } catch { + // ignore + } + await subscription.unsubscribe(); +}; + +/** + * Fetches the canister's VAPID public key. Returns `null` if it hasn't been + * generated yet (which happens before any browser has ever subscribed). + */ +export const getVapidPublicKey = async ( + actor: ActorSubclass<_SERVICE>, +): Promise => { + const result = await actor.push_vapid_public_key(); + if (result.length === 0) return null; + const raw = result[0]; + return raw instanceof Uint8Array ? raw : new Uint8Array(raw); +}; + +/** + * Decodes a base64url string (as used by PushSubscription keys) to bytes. + * `PushSubscription.toJSON()` returns base64url-encoded strings for `p256dh` + * and `auth`; the canister expects raw bytes. + */ +const base64UrlToBytes = (b64url: string): Uint8Array => { + // Restore standard base64 padding/chars. + const padded = b64url.replace(/-/g, "+").replace(/_/g, "/"); + const padding = + padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4)); + const binary = atob(padded + padding); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +}; diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/(postbox)/postbox/+page.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/(postbox)/postbox/+page.svelte new file mode 100644 index 0000000000..49e3d46871 --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/(postbox)/postbox/+page.svelte @@ -0,0 +1,421 @@ + + +
+

+ {$t`Postbox`} +

+

+ {$t`Emails received for your identity.`} +

+ {#if pushSupported} +
+ +
+ {/if} +
+ +
+ +
+
    + {#each emails as email, i} +
  • + +
  • + {/each} +
+
+ + +
+ {#if selectedEmail} +
+

+ {selectedEmail.subject || $t`(no subject)`} +

+

+ {$t`From:`} + {selectedEmail.sender} +

+

+ {$t`To:`} + {selectedEmail.recipient} +

+ {#if selectedEmail.dkim_status[0] !== undefined} + {@const status = selectedEmail.dkim_status[0]} + {@const checks = getChecks(status)} +
+ {#if "Verified" in status} + + + {$t`DKIM verified`} + + {:else if "Pending" in status} + + + {$t`Verifying...`} + + {:else} + + + {$t`Not verified`} + + {/if} +
+ {#if checks !== undefined} +
+ + {$t`Verification details`} + +
    + {#each checks as check} +
  • +
    + {#if isPass(check.status)} + + {:else if isFail(check.status)} + + {:else} + + {/if} + + {checkLabel(check.name)} + +
    + {#if check.detail[0] !== undefined} + + {check.detail[0]} + + {/if} +
  • + {/each} +
+
+ {/if} + {/if} +
+
+
{selectedEmail.body}
+
+ {:else} +

+ {$t`No email selected.`} +

+ {/if} +
+
diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/+layout.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/+layout.svelte index 64fd1bbfe2..421aec5c2b 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/+layout.svelte +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/+layout.svelte @@ -3,6 +3,7 @@ ChevronDownIcon, HouseIcon, KeyRoundIcon, + MailIcon, MenuIcon, XIcon, LifeBuoyIcon, @@ -394,6 +395,17 @@ {$t`Access and recovery`} + {#if data.postboxEmails.length > 0} +
  • + + + {$t`Postbox`} + +
  • + {/if} diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/+layout.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/+layout.ts index 878bdd3693..de961ea512 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/+layout.ts +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/+layout.ts @@ -18,9 +18,18 @@ export const load: LayoutLoad = async ({ url }) => { throw redirect(307, location); } - const identityInfo = await authentication.actor - .identity_info(authentication.identityNumber) - .then(throwCanisterError); + const [identityInfo, postboxEmails] = await Promise.all([ + authentication.actor + .identity_info(authentication.identityNumber) + .then(throwCanisterError), + authentication.actor + .get_postbox(authentication.identityNumber) + .catch(() => []), + ]); - return { identityInfo, identityNumber: authentication.identityNumber }; + return { + identityInfo, + identityNumber: authentication.identityNumber, + postboxEmails, + }; }; diff --git a/src/frontend/static/manifest.json b/src/frontend/static/manifest.json new file mode 100644 index 0000000000..352e9c509c --- /dev/null +++ b/src/frontend/static/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "Internet Identity", + "short_name": "Internet Identity", + "start_url": "/manage", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#ffffff", + "description": "Internet Identity authentication and postbox", + "icons": [ + { + "src": "/favicon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/src/frontend/static/sw.js b/src/frontend/static/sw.js new file mode 100644 index 0000000000..fafded89e5 --- /dev/null +++ b/src/frontend/static/sw.js @@ -0,0 +1,114 @@ +// Internet Identity push notification service worker. +// +// The canister currently sends empty-body pushes. To still show the sender +// and subject in the notification we run a PoC side-channel: the postbox +// page posts a `LATEST_EMAIL` message to this worker whenever it polls, we +// cache the metadata in memory, and the `push` handler reads from that +// cache. When the SW is cold-started by a push (no open page), the cache is +// empty and we fall back to a generic notification. +// +// The proper fix is RFC 8291 payload encryption in the canister — once that +// lands the `push` handler will just read from `event.data` and the +// side-channel code can go. The message handler + cache in this file +// preserve the UX until that ships. + +const POSTBOX_URL = "/manage/postbox"; +// Fragment on the postbox page that marks the email detail pane — lets the +// browser auto-scroll to the email contents on mobile after a click. +const POSTBOX_DETAIL_HASH = "#email-detail"; +// Shared tag for every new-email notification so the browser coalesces +// rapid-fire pushes into a single entry in the notification tray. +const NEW_EMAIL_TAG = "ii-new-email"; + +// PoC: in-memory cache of the most recent email metadata, populated by +// `LATEST_EMAIL` messages from open postbox pages. Cleared on worker +// termination, which is fine — the `push` handler falls back to a generic +// notification when it's null. +let latestEmail = null; + +self.addEventListener("install", (event) => { + // Activate the new worker immediately rather than waiting for all clients + // to close. + event.waitUntil(self.skipWaiting()); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener("message", (event) => { + // Only accept messages from same-origin clients. A compromised iframe or + // hostile popup registered against a different origin must not be able to + // poison the notification cache. + if (event.origin !== self.location.origin) { + return; + } + if (event.data?.type === "LATEST_EMAIL") { + latestEmail = { + from: event.data.from, + subject: event.data.subject, + }; + } +}); + +self.addEventListener("push", (event) => { + // Prefer the sender/subject posted by an open postbox page. Fall back to + // a generic message when the cache is empty (e.g. the worker was + // cold-started by this push event with no page open). + const title = + latestEmail?.from && latestEmail.from.length > 0 + ? latestEmail.from + : "Internet Identity"; + const body = + latestEmail?.subject && latestEmail.subject.length > 0 + ? latestEmail.subject + : "You have a new email in your postbox."; + const options = { + body, + icon: "/favicon.svg", + badge: "/favicon.svg", + // Same tag across all new-email notifications → the platform silently + // replaces any earlier one rather than stacking. `renotify` defaults to + // false, which is what we want: no repeated alert sounds when a backlog + // of queued pushes flushes at once (e.g. after the device comes online). + tag: NEW_EMAIL_TAG, + data: { url: POSTBOX_URL + POSTBOX_DETAIL_HASH }, + }; + event.waitUntil(self.registration.showNotification(title, options)); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const targetUrl = event.notification.data?.url ?? POSTBOX_URL; + + event.waitUntil( + (async () => { + const allClients = await self.clients.matchAll({ + type: "window", + includeUncontrolled: true, + }); + + // Prefer an existing /manage tab so we don't duplicate. + for (const client of allClients) { + const url = new URL(client.url); + if (url.pathname.startsWith("/manage")) { + // Tell the page to jump to the newest email (index 0) and scroll + // the detail pane into view. The page already defaults to index 0 + // on mount, but an already-open tab may be parked on a different + // selection. + client.postMessage({ type: "SHOW_LATEST_EMAIL" }); + try { + await client.navigate(targetUrl); + } catch { + // navigate() can fail if the client is cross-origin; the + // postMessage above is the fallback. + } + return client.focus(); + } + } + + // No existing tab — open a new one. + return self.clients.openWindow(targetUrl); + })(), + ); +}); diff --git a/src/internet_identity/Cargo.toml b/src/internet_identity/Cargo.toml index e6a271ef60..88937b753a 100644 --- a/src/internet_identity/Cargo.toml +++ b/src/internet_identity/Cargo.toml @@ -17,6 +17,7 @@ serde_json = { version = "1.0", default-features = false, features = ["std"] } sha2 = { workspace = true, features = ["oid"] } base64.workspace = true rsa.workspace = true +p256.workspace = true minicbor = { workspace = true, features = ["std", "derive"] } # Captcha deps diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index 5015328128..780c484571 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -1082,6 +1082,99 @@ type ListAvailableAttributesError = variant { AuthorizationError : principal; }; +// SMTP Gateway Protocol types +// ============================ +type SmtpHeader = record { + name : text; + value : text; +}; + +type SmtpMessage = record { + headers : vec SmtpHeader; + body : blob; +}; + +type SmtpAddress = record { + user : text; + domain : text; +}; + +type SmtpEnvelope = record { + from : SmtpAddress; + to : SmtpAddress; +}; + +type SmtpRequest = record { + message : opt SmtpMessage; + envelope : opt SmtpEnvelope; + gateway_flags : opt vec text; +}; + +type SmtpRequestError = record { + code : nat64; + message : text; +}; + +type SmtpResponse = variant { + Ok : record {}; + Err : SmtpRequestError; +}; + +type DkimCheckName = variant { + DkimSignaturePresent; + SignatureParsed; + AlgorithmSupported; + RequiredHeadersSigned; + BodyHashValid; + PublicKeyFetched; + SignatureValid; +}; + +type DkimCheckStatus = variant { + Pass; + Fail; + Skipped; +}; + +type DkimCheck = record { + name : DkimCheckName; + status : DkimCheckStatus; + detail : opt text; +}; + +type DkimVerificationStatus = variant { + Verified : record { checks : vec DkimCheck }; + Unverified : record { checks : vec DkimCheck }; + Pending; +}; + +type PostboxEmail = record { + sender : text; + recipient : text; + subject : text; + body : text; + dkim_status : opt DkimVerificationStatus; +}; + +// --- Web Push notifications --- +type PushSubscription = record { + endpoint : text; + p256dh : blob; + auth : blob; +}; + +type PushSubscribeError = variant { + Unauthorized : principal; + InvalidSubscription : text; + TooManySubscriptions; + InternalCanisterError : text; +}; + +type PushUnsubscribeError = variant { + Unauthorized : principal; + InternalCanisterError : text; +}; + service : (opt InternetIdentityInit) -> { // Legacy identity management API // ============================== @@ -1292,4 +1385,26 @@ service : (opt InternetIdentityInit) -> { // Looks up identity number when called with a recovery phrase lookup_caller_identity_by_recovery_phrase : () -> (opt IdentityNumber); + + // SMTP Gateway Protocol + // ===================== + smtp_request : (SmtpRequest) -> (SmtpResponse); + smtp_request_validate : (SmtpRequest) -> (SmtpResponse) query; + get_postbox : (anchor_number : UserNumber) -> (vec PostboxEmail) query; + + // --- Web Push notifications --- + // Subscribes the caller's browser to push notifications when new emails + // arrive on the given identity's postbox. Generates the canister's VAPID + // key pair on first call if it doesn't yet exist. + push_subscribe : (UserNumber, PushSubscription) -> (variant { Ok; Err : PushSubscribeError }); + // Removes a previously registered push subscription by endpoint URL. + push_unsubscribe : (UserNumber, text) -> (variant { Ok; Err : PushUnsubscribeError }); + // Returns the canister's VAPID public key as raw uncompressed SEC-1 + // bytes (65 bytes). Returns null if no subscriptions have been made yet + // — use push_init_vapid_key to generate it. + push_vapid_public_key : () -> (opt blob) query; + // Ensures the canister has a VAPID key pair and returns the public key. + // The frontend calls this at the start of the opt-in flow to obtain the + // `applicationServerKey` required by PushManager.subscribe(). + push_init_vapid_key : () -> (variant { Ok : blob; Err : PushSubscribeError }); }; diff --git a/src/internet_identity/src/dkim.rs b/src/internet_identity/src/dkim.rs new file mode 100644 index 0000000000..54b68378f8 --- /dev/null +++ b/src/internet_identity/src/dkim.rs @@ -0,0 +1,797 @@ +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +#[cfg(not(test))] +use internet_identity_interface::internet_identity::types::smtp::{ + DkimCheck, DkimCheckName, DkimCheckStatus, DkimVerificationStatus, SmtpHeader, +}; +#[cfg(not(test))] +use rsa::Pkcs1v15Sign; +use rsa::RsaPublicKey; +#[cfg(not(test))] +use sha2::{Digest, Sha256}; + +// --- DKIM-Signature parsing --- + +#[derive(Clone, Debug)] +pub enum Canon { + Simple, + Relaxed, +} + +#[derive(Clone, Debug)] +#[cfg_attr(test, allow(dead_code))] +pub struct DkimSignature { + pub algorithm: String, + pub domain: String, + pub selector: String, + pub signed_headers: Vec, + pub body_hash: Vec, + pub signature: Vec, + pub header_canon: Canon, + pub body_canon: Canon, + pub body_length: Option, + /// `t=` tag — signature creation time (seconds since epoch). Parsed for + /// completeness but not currently enforced. + #[allow(dead_code)] + pub timestamp: Option, + /// `x=` tag — signature expiration time (seconds since epoch). The + /// signature is invalid once the verifier's clock is past this value. + pub expiration: Option, +} + +pub fn parse_dkim_signature(value: &str) -> Result { + let mut tags: Vec<(String, String)> = Vec::new(); + for part in value.split(';') { + let part = part.trim(); + if part.is_empty() { + continue; + } + let (key, val) = part + .split_once('=') + .ok_or_else(|| format!("Invalid tag: {part}"))?; + tags.push((key.trim().to_lowercase(), val.trim().to_string())); + } + + let get = |name: &str| -> Result { + tags.iter() + .find(|(k, _)| k == name) + .map(|(_, v)| v.clone()) + .ok_or_else(|| format!("Missing DKIM tag: {name}")) + }; + + let version = get("v")?; + if version != "1" { + return Err(format!("Unsupported DKIM version: {version}")); + } + + let algorithm = get("a")?; + let domain = get("d")?; + let selector = get("s")?; + + let signed_headers: Vec = get("h")? + .split(':') + .map(|h| h.trim().to_lowercase()) + .collect(); + + // Remove whitespace from base64 values before decoding + let bh_raw = get("bh")?.replace(|c: char| c.is_whitespace(), ""); + let body_hash = BASE64 + .decode(&bh_raw) + .map_err(|e| format!("Invalid base64 in bh: {e}"))?; + + let b_raw = get("b")?.replace(|c: char| c.is_whitespace(), ""); + let signature = BASE64 + .decode(&b_raw) + .map_err(|e| format!("Invalid base64 in b: {e}"))?; + + let (header_canon, body_canon) = match tags.iter().find(|(k, _)| k == "c") { + Some((_, v)) => parse_canonicalization(v)?, + None => (Canon::Simple, Canon::Simple), + }; + + let body_length = tags + .iter() + .find(|(k, _)| k == "l") + .map(|(_, v)| v.parse::()) + .transpose() + .map_err(|e| format!("Invalid body length: {e}"))?; + + let timestamp = tags + .iter() + .find(|(k, _)| k == "t") + .map(|(_, v)| v.parse::()) + .transpose() + .map_err(|e| format!("Invalid timestamp t=: {e}"))?; + + let expiration = tags + .iter() + .find(|(k, _)| k == "x") + .map(|(_, v)| v.parse::()) + .transpose() + .map_err(|e| format!("Invalid expiration x=: {e}"))?; + + Ok(DkimSignature { + algorithm, + domain, + selector, + signed_headers, + body_hash, + signature, + header_canon, + body_canon, + body_length, + timestamp, + expiration, + }) +} + +fn parse_canonicalization(value: &str) -> Result<(Canon, Canon), String> { + let parts: Vec<&str> = value.split('/').collect(); + let header = match parts[0].trim() { + "simple" => Canon::Simple, + "relaxed" => Canon::Relaxed, + other => return Err(format!("Unknown header canonicalization: {other}")), + }; + let body = if parts.len() > 1 { + match parts[1].trim() { + "simple" => Canon::Simple, + "relaxed" => Canon::Relaxed, + other => return Err(format!("Unknown body canonicalization: {other}")), + } + } else { + Canon::Simple + }; + Ok((header, body)) +} + +// --- Canonicalization (RFC 6376 section 3.4) --- + +fn canonicalize_header_relaxed(name: &str, value: &str) -> String { + let name = name.to_lowercase(); + // Unfold lines and compress whitespace + let value = value.replace("\r\n", "").replace('\n', ""); + let value: String = value.split_whitespace().collect::>().join(" "); + let value = value.trim_end(); + format!("{name}:{value}") +} + +fn canonicalize_body_simple(body: &[u8]) -> Vec { + let mut result = body.to_vec(); + // Remove trailing empty lines (CRLF) + while result.ends_with(b"\r\n") { + let len = result.len(); + if len >= 4 && result[len - 4..len - 2] == *b"\r\n" { + result.truncate(len - 2); + } else { + break; + } + } + // Ensure body ends with CRLF + if !result.is_empty() && !result.ends_with(b"\r\n") { + result.extend_from_slice(b"\r\n"); + } + // Empty body is canonicalized to a single CRLF + if result.is_empty() { + result.extend_from_slice(b"\r\n"); + } + result +} + +#[cfg(not(test))] +fn canonicalize_body_relaxed(body: &[u8]) -> Vec { + let text = String::from_utf8_lossy(body); + let mut lines: Vec = text + .lines() + .map(|line| { + // Replace sequences of WSP with a single space + let compressed: String = line + .split([' ', '\t']) + .filter(|s| !s.is_empty()) + .collect::>() + .join(" "); + // Remove trailing whitespace + compressed.trim_end().to_string() + }) + .collect(); + + // Remove trailing empty lines + while lines.last().is_some_and(|l| l.is_empty()) { + lines.pop(); + } + + let mut result: Vec = Vec::new(); + for line in &lines { + result.extend_from_slice(line.as_bytes()); + result.extend_from_slice(b"\r\n"); + } + // Empty body is canonicalized to a single CRLF + if result.is_empty() { + result.extend_from_slice(b"\r\n"); + } + result +} + +// --- Verification (only used in non-test builds via the async orchestrator) --- + +#[cfg(not(test))] +fn verify_body_hash(raw_body: &[u8], sig: &DkimSignature) -> Result<(), String> { + let canonicalized = match sig.body_canon { + Canon::Simple => canonicalize_body_simple(raw_body), + Canon::Relaxed => canonicalize_body_relaxed(raw_body), + }; + + let body_to_hash = match sig.body_length { + Some(len) => &canonicalized[..len.min(canonicalized.len())], + None => &canonicalized, + }; + + let computed = Sha256::digest(body_to_hash); + if computed[..] != sig.body_hash[..] { + return Err("Body hash does not match".into()); + } + Ok(()) +} + +/// Reconstruct the data that was signed per RFC 6376 section 3.7. +#[cfg(not(test))] +fn build_signing_input( + headers: &[SmtpHeader], + dkim_header_value: &str, + sig: &DkimSignature, +) -> Vec { + let mut result: Vec = Vec::new(); + + // Add each signed header in order specified by h= tag. + // For duplicate header names, use headers from bottom to top. + let mut used_indices: Vec = vec![false; headers.len()]; + + for signed_name in &sig.signed_headers { + // Find the last unused header with this name (bottom to top per RFC 6376) + let found = headers + .iter() + .enumerate() + .rev() + .find(|(i, h)| !used_indices[*i] && h.name.eq_ignore_ascii_case(signed_name)); + + if let Some((idx, header)) = found { + used_indices[idx] = true; + // RFC 6376 §3.4.2: simple header canonicalization uses the + // header verbatim as it was received. The SMTP gateway has + // already separated name from value for us, so the best + // approximation is `name:value` — do NOT insert extra + // whitespace, since any space after the colon is part of + // the signed data and adding one will break verification. + let line = match sig.header_canon { + Canon::Relaxed => canonicalize_header_relaxed(&header.name, &header.value), + Canon::Simple => format!("{}:{}", header.name, header.value), + }; + result.extend_from_slice(line.as_bytes()); + result.extend_from_slice(b"\r\n"); + } + } + + // Add the DKIM-Signature header itself, with b= value emptied + let dkim_header_stripped = strip_b_value(dkim_header_value); + let line = match sig.header_canon { + Canon::Relaxed => canonicalize_header_relaxed("dkim-signature", &dkim_header_stripped), + Canon::Simple => format!("DKIM-Signature:{dkim_header_stripped}"), + }; + // No trailing CRLF for the DKIM-Signature header itself + result.extend_from_slice(line.as_bytes()); + + result +} + +/// Remove the value of the b= tag from the DKIM-Signature header value, +/// keeping the tag name and delimiters. +fn strip_b_value(header_value: &str) -> String { + let mut result = String::new(); + let mut remaining = header_value; + + // Find "b=" that is not "bh=" — it follows a semicolon or starts the string + loop { + if let Some(pos) = remaining.find("b=") { + // Ensure this is the "b" tag, not "bh" + if pos > 0 + && remaining.as_bytes()[pos - 1] != b';' + && !remaining[..pos].trim_end().is_empty() + { + // Check if the character before 'b' (ignoring whitespace) is ';' or start + let before = remaining[..pos].trim_end(); + if !before.is_empty() && !before.ends_with(';') { + // Not the b= tag (could be part of another tag value) + result.push_str(&remaining[..pos + 2]); + remaining = &remaining[pos + 2..]; + continue; + } + } + // Check it's not bh= + if remaining.len() > pos + 2 && remaining.as_bytes()[pos + 1] == b'h' { + result.push_str(&remaining[..pos + 3]); + remaining = &remaining[pos + 3..]; + continue; + } + // Found the b= tag — keep "b=" but remove its value up to the next ";" + result.push_str(&remaining[..pos + 2]); + let after_b = &remaining[pos + 2..]; + if let Some(semi) = after_b.find(';') { + remaining = &after_b[semi..]; + } else { + remaining = ""; + } + break; + } else { + result.push_str(remaining); + break; + } + } + result.push_str(remaining); + result +} + +#[cfg(not(test))] +fn verify_rsa_sha256( + signing_input: &[u8], + signature: &[u8], + public_key: &RsaPublicKey, +) -> Result<(), String> { + let hashed = Sha256::digest(signing_input); + let scheme = Pkcs1v15Sign::new::(); + public_key + .verify(scheme, &hashed, signature) + .map_err(|e| format!("RSA verification failed: {e}")) +} + +// --- DKIM DNS public key parsing --- + +/// Minimum RSA key size we accept for DKIM public keys. RFC 6376 allows +/// shorter keys but modern deployment guidance (RFC 8301) is ≥1024 bits, +/// and most reputable senders are on 1024 or 2048. +#[cfg(not(test))] +const DKIM_MIN_RSA_KEY_BITS: usize = 1024; + +/// Parse a DKIM DNS TXT record value (e.g., "v=DKIM1; k=rsa; p=MIGfMA0G...") into an RSA public key. +pub fn parse_dkim_public_key(txt_record: &str) -> Result { + let mut p_value: Option = None; + + for part in txt_record.split(';') { + let part = part.trim(); + if let Some((key, val)) = part.split_once('=') { + // DKIM tag names are case-insensitive per RFC 6376. + if key.trim().eq_ignore_ascii_case("p") { + p_value = Some(val.trim().replace(|c: char| c.is_whitespace(), "")); + } + } + } + + let p_base64 = p_value.ok_or("Missing p= tag in DKIM DNS record")?; + let der_bytes = BASE64 + .decode(&p_base64) + .map_err(|e| format!("Invalid base64 in DKIM public key: {e}"))?; + + use rsa::pkcs8::DecodePublicKey; + let key = RsaPublicKey::from_public_key_der(&der_bytes) + .map_err(|e| format!("Invalid DKIM public key DER: {e}"))?; + + #[cfg(not(test))] + { + use rsa::traits::PublicKeyParts; + let bits = key.n().bits(); + if bits < DKIM_MIN_RSA_KEY_BITS { + return Err(format!( + "DKIM public key too small: {bits} bits (minimum {DKIM_MIN_RSA_KEY_BITS})" + )); + } + } + Ok(key) +} + +// --- DoH fetch --- + +#[cfg(not(test))] +const DOH_CALL_CYCLES: u128 = 30_000_000_000; + +#[cfg(not(test))] +pub async fn fetch_dkim_public_key(selector: &str, domain: &str) -> Result { + use ic_cdk::api::management_canister::http_request::{ + http_request_with_closure, CanisterHttpRequestArgument, HttpHeader, HttpMethod, + }; + + let query_name = format!("{selector}._domainkey.{domain}"); + let url = format!("https://dns.google/resolve?name={query_name}&type=TXT"); + + let request = CanisterHttpRequestArgument { + url, + method: HttpMethod::GET, + body: None, + max_response_bytes: Some(4096), + transform: None, + headers: vec![ + HttpHeader { + name: "Accept".into(), + value: "application/dns-json".into(), + }, + HttpHeader { + name: "User-Agent".into(), + value: "internet_identity_canister".into(), + }, + ], + }; + + let (response,) = http_request_with_closure(request, DOH_CALL_CYCLES, transform_doh_response) + .await + .map_err(|(_, err)| err)?; + + let body: serde_json::Value = serde_json::from_slice(&response.body) + .map_err(|e| format!("Invalid DoH JSON response: {e}"))?; + + let answers = body["Answer"] + .as_array() + .ok_or("No Answer section in DoH response")?; + + // Concatenate all TXT record data fragments + let mut txt_data = String::new(); + for answer in answers { + // Google DNS returns TXT type as 16 + if answer["type"].as_u64() == Some(16) { + if let Some(data) = answer["data"].as_str() { + // Google DNS wraps TXT data in quotes, strip them + let unquoted = data.trim_matches('"'); + txt_data.push_str(unquoted); + } + } + } + + if txt_data.is_empty() { + return Err(format!("No DKIM TXT record found for {query_name}")); + } + + parse_dkim_public_key(&txt_data) +} + +#[cfg(not(test))] +fn transform_doh_response( + response: ic_cdk::api::management_canister::http_request::HttpResponse, +) -> ic_cdk::api::management_canister::http_request::HttpResponse { + use candid::Nat; + use ic_cdk::api::management_canister::http_request::HttpResponse; + + let ok_status = Nat::from(200u32); + if response.status != ok_status { + ic_cdk::api::trap("DoH request returned non-200 status"); + } + + let parsed: serde_json::Value = serde_json::from_slice(&response.body) + .unwrap_or_else(|_| ic_cdk::api::trap("Invalid DoH JSON")); + + // Extract and sort TXT answers for deterministic consensus + let mut txt_records: Vec = Vec::new(); + if let Some(answers) = parsed["Answer"].as_array() { + for answer in answers { + if answer["type"].as_u64() == Some(16) { + if let Some(data) = answer["data"].as_str() { + txt_records.push(data.to_string()); + } + } + } + } + txt_records.sort(); + + let canonical = serde_json::json!({ + "Status": parsed["Status"], + "Answer": txt_records.iter().map(|d| { + serde_json::json!({"type": 16, "data": d}) + }).collect::>() + }); + + let body = serde_json::to_vec(&canonical) + .unwrap_or_else(|_| ic_cdk::api::trap("Failed to serialize canonical DoH response")); + + HttpResponse { + status: ok_status, + headers: vec![], + body, + } +} + +// --- Orchestrator (only compiled in non-test builds) --- + +#[cfg(not(test))] +fn pass(name: DkimCheckName, detail: Option) -> DkimCheck { + DkimCheck { + name, + status: DkimCheckStatus::Pass, + detail, + } +} + +#[cfg(not(test))] +fn fail(name: DkimCheckName, detail: String) -> DkimCheck { + DkimCheck { + name, + status: DkimCheckStatus::Fail, + detail: Some(detail), + } +} + +#[cfg(not(test))] +fn skipped(name: DkimCheckName) -> DkimCheck { + DkimCheck { + name, + status: DkimCheckStatus::Skipped, + detail: None, + } +} + +#[cfg(not(test))] +fn skip_remaining(checks: &mut Vec, names: &[DkimCheckName]) { + for name in names { + checks.push(skipped(name.clone())); + } +} + +#[cfg(not(test))] +pub async fn verify_email_dkim(headers: &[SmtpHeader], raw_body: &[u8]) -> DkimVerificationStatus { + // Collect every DKIM-Signature header. Legitimate multi-hop mail + // commonly carries several — the original signer plus each mailing-list + // forwarder. We accept the email as verified if ANY of them verify, + // since the sender only needs to prove authenticity through one chain. + let dkim_headers: Vec<&SmtpHeader> = headers + .iter() + .filter(|h| h.name.eq_ignore_ascii_case("dkim-signature")) + .collect(); + + if dkim_headers.is_empty() { + let mut checks: Vec = Vec::new(); + checks.push(fail( + DkimCheckName::DkimSignaturePresent, + "No DKIM-Signature header found".into(), + )); + skip_remaining( + &mut checks, + &[ + DkimCheckName::SignatureParsed, + DkimCheckName::AlgorithmSupported, + DkimCheckName::RequiredHeadersSigned, + DkimCheckName::BodyHashValid, + DkimCheckName::PublicKeyFetched, + DkimCheckName::SignatureValid, + ], + ); + return DkimVerificationStatus::Unverified { checks }; + } + + // Try each signature in turn. Keep the last attempt's checks so we have + // something useful to show the user if nothing verifies. + let mut last_checks: Vec = Vec::new(); + for dkim_header in &dkim_headers { + let (checks, verified) = verify_single_signature(dkim_header, headers, raw_body).await; + if verified { + return DkimVerificationStatus::Verified { checks }; + } + last_checks = checks; + } + DkimVerificationStatus::Unverified { + checks: last_checks, + } +} + +/// Runs the full check pipeline against a single DKIM-Signature header and +/// returns its checks plus whether the signature verified. +#[cfg(not(test))] +async fn verify_single_signature( + dkim_header: &SmtpHeader, + headers: &[SmtpHeader], + raw_body: &[u8], +) -> (Vec, bool) { + let mut checks: Vec = Vec::new(); + + // 1. DKIM-Signature header present (always Pass once we reach this + // helper — the caller already filtered). + checks.push(pass(DkimCheckName::DkimSignaturePresent, None)); + + // 2. Signature parsed + let sig = match parse_dkim_signature(&dkim_header.value) { + Ok(s) => { + checks.push(pass( + DkimCheckName::SignatureParsed, + Some(format!("d={}, s={}", s.domain, s.selector)), + )); + s + } + Err(e) => { + checks.push(fail(DkimCheckName::SignatureParsed, e)); + skip_remaining( + &mut checks, + &[ + DkimCheckName::AlgorithmSupported, + DkimCheckName::RequiredHeadersSigned, + DkimCheckName::BodyHashValid, + DkimCheckName::PublicKeyFetched, + DkimCheckName::SignatureValid, + ], + ); + return (checks, false); + } + }; + + // 2b. Signature freshness — if `x=` is set, reject once the verifier's + // clock is past it. Reported inline with the `SignatureValid` + // check at the end (no separate enum variant to stay + // backward-compatible on the Candid wire). + let now_secs = ic_cdk::api::time() / 1_000_000_000; + let expired = matches!(sig.expiration, Some(x) if now_secs > x); + + // 3. Algorithm supported + if sig.algorithm == "rsa-sha256" { + checks.push(pass( + DkimCheckName::AlgorithmSupported, + Some("rsa-sha256".into()), + )); + } else { + checks.push(fail( + DkimCheckName::AlgorithmSupported, + format!("Unsupported: {}", sig.algorithm), + )); + skip_remaining( + &mut checks, + &[ + DkimCheckName::RequiredHeadersSigned, + DkimCheckName::BodyHashValid, + DkimCheckName::PublicKeyFetched, + DkimCheckName::SignatureValid, + ], + ); + return (checks, false); + } + + // 4. Required headers signed + let required = ["from", "to", "subject"]; + let missing: Vec<&str> = required + .iter() + .filter(|req| !sig.signed_headers.iter().any(|h| h == **req)) + .copied() + .collect(); + + if missing.is_empty() { + checks.push(pass( + DkimCheckName::RequiredHeadersSigned, + Some(sig.signed_headers.join(", ")), + )); + } else { + checks.push(fail( + DkimCheckName::RequiredHeadersSigned, + format!("Missing: {}", missing.join(", ")), + )); + skip_remaining( + &mut checks, + &[ + DkimCheckName::BodyHashValid, + DkimCheckName::PublicKeyFetched, + DkimCheckName::SignatureValid, + ], + ); + return (checks, false); + } + + // 5. Body hash valid + if let Err(e) = verify_body_hash(raw_body, &sig) { + checks.push(fail(DkimCheckName::BodyHashValid, e)); + skip_remaining( + &mut checks, + &[ + DkimCheckName::PublicKeyFetched, + DkimCheckName::SignatureValid, + ], + ); + return (checks, false); + } + checks.push(pass(DkimCheckName::BodyHashValid, None)); + + // 6. Public key fetched + let dns_name = format!("{}._domainkey.{}", sig.selector, sig.domain); + let public_key = match fetch_dkim_public_key(&sig.selector, &sig.domain).await { + Ok(pk) => { + checks.push(pass(DkimCheckName::PublicKeyFetched, Some(dns_name))); + pk + } + Err(e) => { + checks.push(fail(DkimCheckName::PublicKeyFetched, e)); + skip_remaining(&mut checks, &[DkimCheckName::SignatureValid]); + return (checks, false); + } + }; + + // 7. Signature valid. An expired signature is treated as a SignatureValid + // failure so the UI surfaces something actionable without needing a + // new Candid variant. + if expired { + checks.push(fail( + DkimCheckName::SignatureValid, + format!( + "Signature expired (x={}, now={})", + sig.expiration.unwrap(), + now_secs + ), + )); + return (checks, false); + } + let signing_input = build_signing_input(headers, &dkim_header.value, &sig); + match verify_rsa_sha256(&signing_input, &sig.signature, &public_key) { + Ok(()) => { + checks.push(pass(DkimCheckName::SignatureValid, None)); + (checks, true) + } + Err(e) => { + checks.push(fail(DkimCheckName::SignatureValid, e)); + (checks, false) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_dkim_signature() { + let value = "v=1; a=rsa-sha256; d=example.com; s=selector1; \ + h=from:to:subject:date; \ + bh=MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=; \ + b=dGVzdA=="; + let sig = parse_dkim_signature(value).unwrap(); + assert_eq!(sig.algorithm, "rsa-sha256"); + assert_eq!(sig.domain, "example.com"); + assert_eq!(sig.selector, "selector1"); + assert_eq!(sig.signed_headers, vec!["from", "to", "subject", "date"]); + } + + #[test] + fn test_parse_dkim_signature_with_canonicalization() { + let value = "v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20230601; \ + h=from:to:subject; bh=dGVzdA==; b=dGVzdA=="; + let sig = parse_dkim_signature(value).unwrap(); + assert!(matches!(sig.header_canon, Canon::Relaxed)); + assert!(matches!(sig.body_canon, Canon::Relaxed)); + } + + #[test] + fn test_strip_b_value() { + assert_eq!( + strip_b_value("v=1; a=rsa-sha256; b=abc123; d=example.com"), + "v=1; a=rsa-sha256; b=; d=example.com" + ); + // bh= should not be affected + assert_eq!( + strip_b_value("bh=hashvalue; b=sigvalue"), + "bh=hashvalue; b=" + ); + } + + #[test] + fn test_canonicalize_header_relaxed() { + assert_eq!( + canonicalize_header_relaxed("From", " John Doe "), + "from:John Doe " + ); + } + + #[test] + fn test_canonicalize_body_simple_trailing_empty_lines() { + let body = b"hello\r\n\r\n\r\n"; + let result = canonicalize_body_simple(body); + assert_eq!(result, b"hello\r\n"); + } + + #[test] + fn test_canonicalize_body_simple_empty() { + let result = canonicalize_body_simple(b""); + assert_eq!(result, b"\r\n"); + } + + #[test] + fn test_parse_dkim_public_key_missing_p() { + let result = parse_dkim_public_key("v=DKIM1; k=rsa"); + assert!(result.is_err()); + } +} diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index fbe5d79c1b..59ddfc6216 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -32,6 +32,12 @@ use internet_identity_interface::internet_identity::types::openid::{ OpenIdCredentialAddError, OpenIdCredentialRemoveError, OpenIdDelegationError, OpenIdPrepareDelegationResponse, }; +use internet_identity_interface::internet_identity::types::push::{ + PushSubscribeError, PushSubscription, PushUnsubscribeError, +}; +use internet_identity_interface::internet_identity::types::smtp::{ + PostboxEmail, SmtpRequest, SmtpResponse, +}; use internet_identity_interface::internet_identity::types::vc_mvp::{ GetIdAliasError, GetIdAliasRequest, IdAliasCredentials, PrepareIdAliasError, PrepareIdAliasRequest, PreparedIdAlias, @@ -55,12 +61,15 @@ mod delegation; mod http; mod ii_domain; +mod dkim; mod openid; +mod smtp; mod state; mod stats; mod storage; mod utils; mod vc_mvp; +mod web_push; // Some time helpers const fn secs_to_nanos(secs: u64) -> u64 { @@ -1571,6 +1580,134 @@ mod attribute_sharing_old_vc { } } +mod smtp_gateway { + use super::*; + + #[update] + fn smtp_request(request: SmtpRequest) -> SmtpResponse { + smtp::handle_smtp_request(request) + } + + #[query] + fn smtp_request_validate(request: SmtpRequest) -> SmtpResponse { + smtp::handle_smtp_request_validate(request) + } + + #[query] + fn get_postbox(anchor_number: AnchorNumber) -> Vec { + smtp::get_postbox(anchor_number) + } +} + +mod push_api { + use super::*; + use crate::authz_utils::check_authz_and_record_activity; + use internet_identity_interface::internet_identity::types::push::{ + MAX_PUSH_ENDPOINT_BYTES, PUSH_AUTH_SECRET_BYTES, PUSH_P256DH_KEY_BYTES, + }; + + /// Subscribes the caller's browser for Web Push notifications when new + /// emails arrive on the given anchor. + /// + /// The caller must be authorized for `anchor_number`. Subscribing again + /// with the same endpoint replaces the previous entry. + #[update] + async fn push_subscribe( + anchor_number: AnchorNumber, + subscription: PushSubscription, + ) -> Result<(), PushSubscribeError> { + check_authz_and_record_activity(anchor_number) + .map_err(|_| PushSubscribeError::Unauthorized(ic_cdk::caller()))?; + + if subscription.endpoint.is_empty() || subscription.endpoint.len() > MAX_PUSH_ENDPOINT_BYTES + { + return Err(PushSubscribeError::InvalidSubscription( + "endpoint length out of range".into(), + )); + } + if subscription.p256dh.len() != PUSH_P256DH_KEY_BYTES { + return Err(PushSubscribeError::InvalidSubscription(format!( + "p256dh key must be exactly {PUSH_P256DH_KEY_BYTES} bytes" + ))); + } + if subscription.auth.len() != PUSH_AUTH_SECRET_BYTES { + return Err(PushSubscribeError::InvalidSubscription(format!( + "auth secret must be exactly {PUSH_AUTH_SECRET_BYTES} bytes" + ))); + } + + // Make sure the VAPID key exists before recording the subscription — + // otherwise the first few emails would dispatch with no key and never + // fire a push. + #[cfg(not(test))] + { + web_push::ensure_vapid_key() + .await + .map_err(PushSubscribeError::InternalCanisterError)?; + } + + state::storage_borrow_mut(|s| s.add_push_subscription(anchor_number, subscription)); + Ok(()) + } + + /// Unsubscribes a specific push endpoint for the given anchor. No-ops if + /// the endpoint was not subscribed. + #[update] + fn push_unsubscribe( + anchor_number: AnchorNumber, + endpoint: String, + ) -> Result<(), PushUnsubscribeError> { + check_authz_and_record_activity(anchor_number) + .map_err(|_| PushUnsubscribeError::Unauthorized(ic_cdk::caller()))?; + + state::storage_borrow_mut(|s| s.remove_push_subscription(anchor_number, &endpoint)); + Ok(()) + } + + /// Returns the canister's VAPID public key (65 bytes, uncompressed SEC-1 + /// encoding). Returns `None` if the key hasn't been generated yet — the + /// frontend should then call [`push_init_vapid_key`] to trigger generation. + #[query] + fn push_vapid_public_key() -> Option { + #[cfg(not(test))] + { + web_push::vapid_public_key_bytes().map(ByteBuf::from) + } + #[cfg(test)] + { + None + } + } + + /// Ensures the canister has a VAPID key pair and returns the public key. + /// + /// This is an update call so it can generate a key via raw_rand and + /// persist it. The frontend calls this at the start of the opt-in flow + /// to obtain the `applicationServerKey` required by PushManager.subscribe(). + #[update] + async fn push_init_vapid_key() -> Result { + #[cfg(not(test))] + { + web_push::ensure_vapid_key() + .await + .map_err(PushSubscribeError::InternalCanisterError)?; + web_push::vapid_public_key_bytes() + .map(ByteBuf::from) + .ok_or_else(|| { + PushSubscribeError::InternalCanisterError( + "VAPID key missing immediately after generation".into(), + ) + }) + } + #[cfg(test)] + { + Err(PushSubscribeError::InternalCanisterError( + "push notifications are not available in test builds".into(), + )) + } + } +} + fn main() {} // Order dependent: do not move above any exposed canister method! diff --git a/src/internet_identity/src/smtp.rs b/src/internet_identity/src/smtp.rs new file mode 100644 index 0000000000..9e62d68015 --- /dev/null +++ b/src/internet_identity/src/smtp.rs @@ -0,0 +1,93 @@ +use crate::state; +use crate::storage::storable::smtp::StorableEmail; +use internet_identity_interface::internet_identity::types::smtp::{ + validate_envelope_only, DkimVerificationStatus, PostboxEmail, SmtpRequest, SmtpResponse, + ValidatedSmtpRequest, +}; +use internet_identity_interface::internet_identity::types::AnchorNumber; + +pub fn handle_smtp_request(request: SmtpRequest) -> SmtpResponse { + let validated: ValidatedSmtpRequest = match request.try_into() { + Ok(v) => v, + Err(response) => return response, + }; + + let recipient_key = validated.anchor_number.to_string(); + + let email = StorableEmail { + sender: validated.sender, + recipient: validated.recipient, + subject: validated.subject, + body: validated.body, + dkim_status: Some(DkimVerificationStatus::Pending), + }; + + // Spawn async DKIM verification (best-effort, fire-and-forget), then + // dispatch push notifications only if verification succeeded. Unverified + // emails are still stored and visible in the postbox, they just don't + // interrupt the user with a device notification. + // In non-test builds, the headers/body/index are consumed by the spawned task. + // In test builds, the spawn block is stripped so we suppress unused warnings. + #[cfg(not(test))] + { + let headers = validated.headers; + let raw_body = validated.raw_body; + let email_index = + state::storage_borrow_mut(|storage| storage.store_email(recipient_key.clone(), email)); + + ic_cdk::spawn(async move { + let status = crate::dkim::verify_email_dkim(&headers, &raw_body).await; + let verified = matches!(&status, DkimVerificationStatus::Verified { .. }); + state::storage_borrow_mut(|storage| { + storage.update_email_dkim_status(recipient_key.clone(), email_index, status); + }); + if verified { + crate::web_push::dispatch_push_for_anchor(&recipient_key).await; + } + }); + } + + #[cfg(test)] + { + let _ = &validated.headers; + let _ = &validated.raw_body; + state::storage_borrow_mut(|storage| { + storage.store_email(recipient_key, email); + }); + } + + SmtpResponse::Ok {} +} + +pub fn get_postbox(anchor_number: AnchorNumber) -> Vec { + state::storage_borrow(|storage| { + storage + .get_emails(&anchor_number.to_string()) + .into_iter() + .rev() + .map(|e| PostboxEmail { + sender: e.sender, + recipient: e.recipient, + subject: e.subject, + body: e.body, + dkim_status: e.dkim_status, + }) + .collect() + }) +} + +pub fn handle_smtp_request_validate(request: SmtpRequest) -> SmtpResponse { + // If a full message is present, run full validation + if request.message.is_some() { + return match ValidatedSmtpRequest::try_from(request) { + Ok(_) => SmtpResponse::Ok {}, + Err(response) => response, + }; + } + + // Otherwise validate just the envelope (and whatever else is present) + match validate_envelope_only(&request) { + Ok(()) => SmtpResponse::Ok {}, + Err(response) => response, + } +} diff --git a/src/internet_identity/src/state.rs b/src/internet_identity/src/state.rs index 700c1248c5..5000edc2b1 100644 --- a/src/internet_identity/src/state.rs +++ b/src/internet_identity/src/state.rs @@ -131,6 +131,9 @@ pub struct PersistentState { pub enable_dapps_explorer: Option, pub is_production: Option, pub dummy_auth: Option, + // VAPID private key (PKCS#8 DER-encoded P-256) for Web Push notifications. + // Generated lazily on first push subscription. + pub vapid_key: Option>, } impl Default for PersistentState { @@ -154,6 +157,7 @@ impl Default for PersistentState { enable_dapps_explorer: None, is_production: None, dummy_auth: None, + vapid_key: None, } } } diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index eefa64a481..69cf0f1480 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -130,6 +130,8 @@ use storable::discrepancy_counter::{DiscrepancyType, StorableDiscrepancyCounter} use storable::fixed_anchor::StorableFixedAnchor; use storable::openid_credential::StorableOpenIdCredential; use storable::openid_credential_key::StorableOpenIdCredentialKey; +use storable::push_subscription::StorablePushSubscriptionList; +use storable::smtp::{StorableEmail, StorableEmailAddress, StorableEmailList}; use storable::storable_persistent_state::StorablePersistentState; pub mod anchor; @@ -178,6 +180,8 @@ const LOOKUP_APPLICATION_WITH_ORIGIN_MEMORY_INDEX: u8 = 19u8; const STABLE_ANCHOR_APPLICATION_CONFIG_MEMORY_INDEX: u8 = 20u8; const LOOKUP_ANCHOR_WITH_RECOVERY_PHRASE_PRINCIPAL_MEMORY_INDEX: u8 = 21u8; const LOOKUP_ANCHOR_WITH_PASSKEY_PUBKEY_HASH_MEMORY_INDEX: u8 = 22u8; +const SMTP_POSTBOX_MEMORY_INDEX: u8 = 23u8; +const PUSH_SUBSCRIPTION_MEMORY_INDEX: u8 = 24u8; const ANCHOR_MEMORY_ID: MemoryId = MemoryId::new(ANCHOR_MEMORY_INDEX); const ARCHIVE_BUFFER_MEMORY_ID: MemoryId = MemoryId::new(ARCHIVE_BUFFER_MEMORY_INDEX); @@ -215,6 +219,9 @@ const LOOKUP_ANCHOR_WITH_RECOVERY_PHRASE_PRINCIPAL_MEMORY_ID: MemoryId = const LOOKUP_ANCHOR_WITH_PASSKEY_PUBKEY_HASH_MEMORY_ID: MemoryId = MemoryId::new(LOOKUP_ANCHOR_WITH_PASSKEY_PUBKEY_HASH_MEMORY_INDEX); +const SMTP_POSTBOX_MEMORY_ID: MemoryId = MemoryId::new(SMTP_POSTBOX_MEMORY_INDEX); +const PUSH_SUBSCRIPTION_MEMORY_ID: MemoryId = MemoryId::new(PUSH_SUBSCRIPTION_MEMORY_INDEX); + // The bucket size 128 is relatively low, to avoid wasting memory when using // multiple virtual memories for smaller amounts of data. // This value results in 256 GB of total managed memory, which should be enough @@ -339,6 +346,13 @@ pub struct Storage { lookup_anchor_with_passkey_pubkey_hash_memory_wrapper: MemoryWrapper>, pub(crate) lookup_anchor_with_passkey_pubkey_hash_memory: StableBTreeMap>, + + smtp_postbox_memory_wrapper: MemoryWrapper>, + smtp_postbox: StableBTreeMap>, + + push_subscription_memory_wrapper: MemoryWrapper>, + push_subscriptions: + StableBTreeMap>, } #[repr(C, packed)] @@ -422,6 +436,8 @@ impl Storage { memory_manager.get(LOOKUP_ANCHOR_WITH_RECOVERY_PHRASE_PRINCIPAL_MEMORY_ID); let lookup_anchor_with_passkey_pubkey_hash_memory = memory_manager.get(LOOKUP_ANCHOR_WITH_PASSKEY_PUBKEY_HASH_MEMORY_ID); + let smtp_postbox_memory = memory_manager.get(SMTP_POSTBOX_MEMORY_ID); + let push_subscription_memory = memory_manager.get(PUSH_SUBSCRIPTION_MEMORY_ID); let registration_rates = RegistrationRates::new( MinHeap::init(registration_ref_rate_memory.clone()) @@ -522,6 +538,10 @@ impl Storage { lookup_anchor_with_passkey_pubkey_hash_memory: StableBTreeMap::init( lookup_anchor_with_passkey_pubkey_hash_memory, ), + smtp_postbox_memory_wrapper: MemoryWrapper::new(smtp_postbox_memory.clone()), + smtp_postbox: StableBTreeMap::init(smtp_postbox_memory), + push_subscription_memory_wrapper: MemoryWrapper::new(push_subscription_memory.clone()), + push_subscriptions: StableBTreeMap::init(push_subscription_memory), } } @@ -765,9 +785,8 @@ impl Storage { let credential_to_be_removed = previous_set.difference(¤t_set); let credential_to_be_added = current_set.difference(&previous_set); - credential_to_be_removed.cloned().for_each(|key| { - self.lookup_anchor_with_openid_credential_memory - .remove(&key); + credential_to_be_removed.for_each(|key| { + self.lookup_anchor_with_openid_credential_memory.remove(key); }); credential_to_be_added.cloned().for_each(|key| { self.lookup_anchor_with_openid_credential_memory @@ -1944,6 +1963,112 @@ impl Storage { self.header.version } + pub fn get_emails(&self, recipient: &str) -> Vec { + let key = StorableEmailAddress(recipient.to_string()); + self.smtp_postbox + .get(&key) + .map(|list| list.emails) + .unwrap_or_default() + } + + /// Stores an email and returns the index of the newly stored email within the list. + pub fn store_email(&mut self, recipient: String, email: StorableEmail) -> usize { + use internet_identity_interface::internet_identity::types::smtp::MAX_EMAILS_PER_USER; + + let key = StorableEmailAddress(recipient); + let mut list = self + .smtp_postbox + .get(&key) + .unwrap_or(StorableEmailList { emails: Vec::new() }); + + list.emails.push(email); + + // Keep only the most recent emails + if list.emails.len() > MAX_EMAILS_PER_USER { + let start = list.emails.len() - MAX_EMAILS_PER_USER; + list.emails = list.emails.split_off(start); + } + + let index = list.emails.len() - 1; + self.smtp_postbox.insert(key, list); + index + } + + #[cfg_attr(test, allow(dead_code))] + pub fn update_email_dkim_status( + &mut self, + recipient: String, + email_index: usize, + status: internet_identity_interface::internet_identity::types::smtp::DkimVerificationStatus, + ) { + let key = StorableEmailAddress(recipient); + if let Some(mut list) = self.smtp_postbox.get(&key) { + if let Some(email) = list.emails.get_mut(email_index) { + email.dkim_status = Some(status); + self.smtp_postbox.insert(key, list); + } + } + } + + /// Returns all push subscriptions stored for the given anchor. + #[cfg_attr(test, allow(dead_code))] + pub fn get_push_subscriptions( + &self, + anchor: AnchorNumber, + ) -> Vec { + self.push_subscriptions + .get(&anchor) + .map(|list| list.subscriptions) + .unwrap_or_default() + } + + /// Adds a push subscription for the given anchor. + /// + /// If a subscription with the same endpoint already exists, it is replaced + /// (endpoints are globally unique per the Web Push spec). Enforces the + /// per-user cap of [`MAX_PUSH_SUBSCRIPTIONS_PER_USER`] by evicting the + /// oldest subscription when full. + pub fn add_push_subscription( + &mut self, + anchor: AnchorNumber, + subscription: internet_identity_interface::internet_identity::types::push::PushSubscription, + ) { + use internet_identity_interface::internet_identity::types::push::MAX_PUSH_SUBSCRIPTIONS_PER_USER; + + let mut list = + self.push_subscriptions + .get(&anchor) + .unwrap_or(StorablePushSubscriptionList { + subscriptions: Vec::new(), + }); + + // Replace any existing subscription for the same endpoint. + list.subscriptions + .retain(|s| s.endpoint != subscription.endpoint); + list.subscriptions.push(subscription); + + // Keep only the most recent subscriptions. + if list.subscriptions.len() > MAX_PUSH_SUBSCRIPTIONS_PER_USER { + let start = list.subscriptions.len() - MAX_PUSH_SUBSCRIPTIONS_PER_USER; + list.subscriptions = list.subscriptions.split_off(start); + } + + self.push_subscriptions.insert(anchor, list); + } + + /// Removes a single push subscription by endpoint. Silently no-ops if + /// the anchor or endpoint does not exist. + pub fn remove_push_subscription(&mut self, anchor: AnchorNumber, endpoint: &str) { + if let Some(mut list) = self.push_subscriptions.get(&anchor) { + list.subscriptions.retain(|s| s.endpoint != endpoint); + if list.subscriptions.is_empty() { + self.push_subscriptions.remove(&anchor); + } else { + self.push_subscriptions.insert(anchor, list); + } + } + } + pub fn memory_sizes(&self) -> HashMap { HashMap::from_iter(vec![ ("header".to_string(), self.header_memory.size()), @@ -2020,6 +2145,14 @@ impl Storage { self.lookup_anchor_with_passkey_pubkey_hash_memory_wrapper .size(), ), + ( + "smtp_postbox".to_string(), + self.smtp_postbox_memory_wrapper.size(), + ), + ( + "push_subscriptions".to_string(), + self.push_subscription_memory_wrapper.size(), + ), ]) } } diff --git a/src/internet_identity/src/storage/storable.rs b/src/internet_identity/src/storage/storable.rs index 06835fc7cc..ceba84614c 100644 --- a/src/internet_identity/src/storage/storable.rs +++ b/src/internet_identity/src/storage/storable.rs @@ -16,6 +16,8 @@ pub mod metadata_v2; pub mod openid_credential; pub mod openid_credential_key; pub mod passkey_credential; +pub mod push_subscription; pub mod recovery_key; +pub mod smtp; pub mod special_device_migration; pub mod storable_persistent_state; diff --git a/src/internet_identity/src/storage/storable/push_subscription.rs b/src/internet_identity/src/storage/storable/push_subscription.rs new file mode 100644 index 0000000000..ec0457ead1 --- /dev/null +++ b/src/internet_identity/src/storage/storable/push_subscription.rs @@ -0,0 +1,39 @@ +use candid::{CandidType, Deserialize}; +use ic_stable_structures::storable::Bound; +use ic_stable_structures::Storable; +use internet_identity_interface::internet_identity::types::push::{ + PushSubscription, MAX_PUSH_ENDPOINT_BYTES, MAX_PUSH_SUBSCRIPTIONS_PER_USER, + PUSH_AUTH_SECRET_BYTES, PUSH_P256DH_KEY_BYTES, +}; +use std::borrow::Cow; + +/// Max serialized size of a single push subscription: +/// endpoint (2048) + p256dh (65) + auth (16) + Candid overhead (~50 bytes). +const STORABLE_PUSH_SUBSCRIPTION_MAX_SIZE: u32 = MAX_PUSH_ENDPOINT_BYTES as u32 + + PUSH_P256DH_KEY_BYTES as u32 + + PUSH_AUTH_SECRET_BYTES as u32 + + 50; + +/// Max serialized size of the push subscription list value. +const STORABLE_PUSH_SUBSCRIPTION_LIST_MAX_SIZE: u32 = + STORABLE_PUSH_SUBSCRIPTION_MAX_SIZE * MAX_PUSH_SUBSCRIPTIONS_PER_USER as u32 + 100; + +#[derive(Clone, Debug, CandidType, Deserialize)] +pub struct StorablePushSubscriptionList { + pub subscriptions: Vec, +} + +impl Storable for StorablePushSubscriptionList { + fn to_bytes(&self) -> Cow<'_, [u8]> { + Cow::Owned(candid::encode_one(self).expect("failed to encode StorablePushSubscriptionList")) + } + + fn from_bytes(bytes: Cow<'_, [u8]>) -> Self { + candid::decode_one(&bytes).expect("failed to decode StorablePushSubscriptionList") + } + + const BOUND: Bound = Bound::Bounded { + max_size: STORABLE_PUSH_SUBSCRIPTION_LIST_MAX_SIZE, + is_fixed_size: false, + }; +} diff --git a/src/internet_identity/src/storage/storable/smtp.rs b/src/internet_identity/src/storage/storable/smtp.rs new file mode 100644 index 0000000000..6f122bcedd --- /dev/null +++ b/src/internet_identity/src/storage/storable/smtp.rs @@ -0,0 +1,72 @@ +use candid::{CandidType, Deserialize}; +use ic_stable_structures::storable::Bound; +use ic_stable_structures::Storable; +use internet_identity_interface::internet_identity::types::smtp::{ + DkimVerificationStatus, MAX_BODY_BYTES, MAX_EMAILS_PER_USER, MAX_EMAIL_DOMAIN_BYTES, + MAX_EMAIL_USER_BYTES, MAX_SUBJECT_BYTES, +}; +use std::borrow::Cow; + +/// Max serialized size of a single email address key. +/// user (64) + "@" (1) + domain (255) + Candid overhead (~20 bytes). +const STORABLE_EMAIL_ADDRESS_MAX_SIZE: u32 = + (MAX_EMAIL_USER_BYTES + 1 + MAX_EMAIL_DOMAIN_BYTES + 20) as u32; + +/// Max serialized size of a single email. +/// sender (320) + recipient (320) + subject (256) + body (5_000) +/// + DKIM checks (7 × ~140 bytes) + Candid overhead (~120 bytes). +const STORABLE_EMAIL_MAX_SIZE: u32 = (MAX_EMAIL_USER_BYTES + 1 + MAX_EMAIL_DOMAIN_BYTES) as u32 * 2 + + MAX_SUBJECT_BYTES as u32 + + MAX_BODY_BYTES as u32 + + 1100; + +/// Max serialized size of the email list value. +const STORABLE_EMAIL_LIST_MAX_SIZE: u32 = + STORABLE_EMAIL_MAX_SIZE * MAX_EMAILS_PER_USER as u32 + 100; + +#[derive(Clone, Debug, CandidType, Deserialize, Ord, PartialOrd, Eq, PartialEq)] +pub struct StorableEmailAddress(pub String); + +impl Storable for StorableEmailAddress { + fn to_bytes(&self) -> Cow<'_, [u8]> { + Cow::Owned(candid::encode_one(self).expect("failed to encode StorableEmailAddress")) + } + + fn from_bytes(bytes: Cow<'_, [u8]>) -> Self { + candid::decode_one(&bytes).expect("failed to decode StorableEmailAddress") + } + + const BOUND: Bound = Bound::Bounded { + max_size: STORABLE_EMAIL_ADDRESS_MAX_SIZE, + is_fixed_size: false, + }; +} + +#[derive(Clone, Debug, CandidType, Deserialize)] +pub struct StorableEmail { + pub sender: String, + pub recipient: String, + pub subject: String, + pub body: String, + pub dkim_status: Option, +} + +#[derive(Clone, Debug, CandidType, Deserialize)] +pub struct StorableEmailList { + pub emails: Vec, +} + +impl Storable for StorableEmailList { + fn to_bytes(&self) -> Cow<'_, [u8]> { + Cow::Owned(candid::encode_one(self).expect("failed to encode StorableEmailList")) + } + + fn from_bytes(bytes: Cow<'_, [u8]>) -> Self { + candid::decode_one(&bytes).expect("failed to decode StorableEmailList") + } + + const BOUND: Bound = Bound::Bounded { + max_size: STORABLE_EMAIL_LIST_MAX_SIZE, + is_fixed_size: false, + }; +} diff --git a/src/internet_identity/src/storage/storable/storable_persistent_state.rs b/src/internet_identity/src/storage/storable/storable_persistent_state.rs index b9ec90e30a..e176a2a7b0 100644 --- a/src/internet_identity/src/storage/storable/storable_persistent_state.rs +++ b/src/internet_identity/src/storage/storable/storable_persistent_state.rs @@ -42,6 +42,8 @@ pub struct StorablePersistentState { enable_dapps_explorer: Option, is_production: Option, dummy_auth: Option, + // VAPID private key (PKCS#8 DER-encoded P-256) for Web Push notifications. + vapid_key: Option>, } impl Storable for StorablePersistentState { @@ -88,6 +90,7 @@ impl From for StorablePersistentState { enable_dapps_explorer: s.enable_dapps_explorer, is_production: s.is_production, dummy_auth: s.dummy_auth, + vapid_key: s.vapid_key, } } } @@ -112,6 +115,7 @@ impl From for PersistentState { enable_dapps_explorer: s.enable_dapps_explorer, is_production: s.is_production, dummy_auth: s.dummy_auth, + vapid_key: s.vapid_key, } } } @@ -164,6 +168,7 @@ mod tests { enable_dapps_explorer: None, is_production: None, dummy_auth: None, + vapid_key: None, }; pretty_assertions::assert_eq!(StorablePersistentState::default(), expected_defaults); @@ -192,6 +197,7 @@ mod tests { enable_dapps_explorer: None, is_production: None, dummy_auth: None, + vapid_key: None, }; pretty_assertions::assert_eq!(PersistentState::default(), expected_defaults); } diff --git a/src/internet_identity/src/web_push.rs b/src/internet_identity/src/web_push.rs new file mode 100644 index 0000000000..9a35f341dc --- /dev/null +++ b/src/internet_identity/src/web_push.rs @@ -0,0 +1,298 @@ +//! Web Push notifications for postbox emails. +//! +//! Sends VAPID-signed (RFC 8292) HTTP POSTs to push service endpoints when a +//! new email arrives. V1 sends empty payloads only — the service worker shows +//! a generic "you have a new email" notification and the user navigates to the +//! postbox. Payload encryption (RFC 8291) is deferred. +//! +//! The canister generates and stores an ECDSA P-256 VAPID key pair lazily on +//! first subscription. Subscriptions are stored per anchor. +//! +//! **Future optimization:** Push messages are self-authenticating via VAPID, +//! so a batch of outgoing pushes could be forwarded through a single non-IC +//! relay in one outcall rather than one outcall per subscription. Also, once +//! the project upgrades `ic-cdk` past a version that exposes the +//! `is_replicated: false` flag, switch to non-replicated outcalls — push +//! dispatch is a fire-and-forget side-effect and does not need consensus. + +#[cfg(not(test))] +use crate::state; +#[cfg(not(test))] +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +#[cfg(not(test))] +use candid::Principal; +#[cfg(not(test))] +use ic_cdk::api::call::call; +#[cfg(not(test))] +use ic_cdk::api::management_canister::http_request::{ + http_request_with_closure, CanisterHttpRequestArgument, HttpHeader, HttpMethod, HttpResponse, +}; +#[cfg(not(test))] +use internet_identity_interface::internet_identity::types::AnchorNumber; +#[cfg(not(test))] +use p256::{ + ecdsa::{signature::Signer, Signature, SigningKey}, + elliptic_curve::sec1::ToEncodedPoint, + pkcs8::{DecodePrivateKey, EncodePrivateKey}, + SecretKey, +}; + +#[cfg(not(test))] +const PUSH_CALL_CYCLES: u128 = 30_000_000_000; +/// VAPID JWT validity window: 12 hours. Push services reject JWTs with `exp` +/// more than 24 hours in the future. +#[cfg(not(test))] +const VAPID_JWT_TTL_SECONDS: u64 = 12 * 60 * 60; +/// How long push services should retain an undelivered message (seconds). +#[cfg(not(test))] +const PUSH_TTL_SECONDS: u64 = 24 * 60 * 60; +/// `sub` claim in the VAPID JWT — a contact URI in case the push service +/// needs to reach us about abusive traffic. +#[cfg(not(test))] +const VAPID_CONTACT: &str = "mailto:noreply@internetcomputer.org"; +/// `Topic` header value (RFC 8030 §5.4) shared by every new-email push. Push +/// services use this to collapse older undelivered messages of the same topic +/// for a subscription — so a user whose device was offline during a burst of +/// arrivals receives only the most recent one when it comes back online. +#[cfg(not(test))] +const PUSH_TOPIC: &str = "ii-new-email"; + +// --------------------------------------------------------------------------- +// VAPID key management +// --------------------------------------------------------------------------- + +/// Returns the stored VAPID private key, generating one on first call. +#[cfg(not(test))] +pub async fn ensure_vapid_key() -> Result, String> { + if let Some(key) = state::persistent_state(|ps| ps.vapid_key.clone()) { + return Ok(key); + } + + let random_bytes: Vec = match call(Principal::management_canister(), "raw_rand", ()).await { + Ok((bytes,)) => bytes, + Err((_, err)) => return Err(format!("raw_rand call failed: {err}")), + }; + + if random_bytes.len() < 32 { + return Err("raw_rand returned fewer than 32 bytes".to_string()); + } + + let secret_key = SecretKey::from_slice(&random_bytes[..32]) + .map_err(|e| format!("invalid P-256 secret key: {e}"))?; + let der = secret_key + .to_pkcs8_der() + .map_err(|e| format!("PKCS#8 encoding failed: {e}"))?; + let key_bytes = der.as_bytes().to_vec(); + + state::persistent_state_mut(|ps| ps.vapid_key = Some(key_bytes.clone())); + state::save_persistent_state(); + Ok(key_bytes) +} + +/// Returns the VAPID public key as 65 uncompressed SEC-1 bytes +/// (`0x04 || X || Y`), or `None` if the key hasn't been generated yet. +#[cfg(not(test))] +pub fn vapid_public_key_bytes() -> Option> { + let der = state::persistent_state(|ps| ps.vapid_key.clone())?; + let sk = SecretKey::from_pkcs8_der(&der).ok()?; + let encoded_point = sk.public_key().to_encoded_point(false); + Some(encoded_point.as_bytes().to_vec()) +} + +// --------------------------------------------------------------------------- +// VAPID JWT construction +// --------------------------------------------------------------------------- + +/// Extracts the origin (`scheme://host[:port]`) from a URL. Returns `None` +/// for malformed URLs. +#[cfg(not(test))] +fn extract_origin(endpoint: &str) -> Option { + let after_scheme = endpoint.split_once("://")?; + let scheme = after_scheme.0; + if scheme != "https" && scheme != "http" { + return None; + } + let rest = after_scheme.1; + let host = match rest.find('/') { + Some(idx) => &rest[..idx], + None => rest, + }; + if host.is_empty() { + return None; + } + Some(format!("{scheme}://{host}")) +} + +#[cfg(not(test))] +fn base64url(input: &[u8]) -> String { + URL_SAFE_NO_PAD.encode(input) +} + +/// Builds a VAPID JWT (RFC 8292) for the given push service audience (origin). +#[cfg(not(test))] +fn create_vapid_jwt(audience: &str, vapid_key_der: &[u8]) -> Result { + let sk = + SecretKey::from_pkcs8_der(vapid_key_der).map_err(|e| format!("invalid VAPID key: {e}"))?; + + let header_b64 = base64url(br#"{"typ":"JWT","alg":"ES256"}"#); + let now_secs = ic_cdk::api::time() / 1_000_000_000; + let claims = format!( + r#"{{"aud":"{}","exp":{},"sub":"{}"}}"#, + audience, + now_secs + VAPID_JWT_TTL_SECONDS, + VAPID_CONTACT + ); + let claims_b64 = base64url(claims.as_bytes()); + let signing_input = format!("{header_b64}.{claims_b64}"); + + let signing_key = SigningKey::from(sk); + let signature: Signature = signing_key.sign(signing_input.as_bytes()); + // Push services expect raw `r || s` (64 bytes), not DER. + let sig_b64 = base64url(&signature.to_bytes()); + + Ok(format!("{signing_input}.{sig_b64}")) +} + +// --------------------------------------------------------------------------- +// HTTP outcall +// --------------------------------------------------------------------------- + +/// Outcome of a single push attempt. +#[cfg(not(test))] +#[derive(Debug)] +enum PushError { + /// Endpoint returned 404/410 — subscription is gone, caller should remove. + SubscriptionGone, + /// Any other failure (transient or permanent). Caller should log and keep + /// the subscription. + Other(String), +} + +#[cfg(not(test))] +async fn send_push_to_endpoint(endpoint: &str, vapid_key_der: &[u8]) -> Result<(), PushError> { + let audience = extract_origin(endpoint) + .ok_or_else(|| PushError::Other(format!("malformed endpoint URL: {endpoint}")))?; + let jwt = create_vapid_jwt(&audience, vapid_key_der).map_err(PushError::Other)?; + + let public_key_bytes = + vapid_public_key_bytes().ok_or_else(|| PushError::Other("VAPID key missing".into()))?; + let public_key_b64 = base64url(&public_key_bytes); + + let request = CanisterHttpRequestArgument { + url: endpoint.to_string(), + method: HttpMethod::POST, + body: Some(vec![]), + max_response_bytes: Some(1024), + transform: None, + headers: vec![ + HttpHeader { + name: "Authorization".into(), + value: format!("vapid t={jwt}, k={public_key_b64}"), + }, + HttpHeader { + name: "TTL".into(), + value: PUSH_TTL_SECONDS.to_string(), + }, + HttpHeader { + name: "Topic".into(), + value: PUSH_TOPIC.to_string(), + }, + HttpHeader { + name: "Content-Length".into(), + value: "0".into(), + }, + ], + }; + + let (response,) = http_request_with_closure(request, PUSH_CALL_CYCLES, transform_push_response) + .await + .map_err(|(_, err)| PushError::Other(err))?; + + // `response.status` is a `candid::Nat`. Compare against a few well-known + // HTTP codes; treat 2xx as success. + let status_str = response.status.0.to_string(); + let status: u16 = status_str.parse().unwrap_or(0); + match status { + 200..=299 => Ok(()), + 404 | 410 => Err(PushError::SubscriptionGone), + _ => Err(PushError::Other(format!( + "push service returned status {status}" + ))), + } +} + +/// Deterministic transform for the push service response. +/// +/// Push service response bodies (e.g. FCM message IDs) and timestamp headers +/// vary per replica, which would break consensus. We discard them and keep +/// only the status code — that's all we need to decide success/failure. +#[cfg(not(test))] +fn transform_push_response(response: HttpResponse) -> HttpResponse { + HttpResponse { + status: response.status, + headers: vec![], + body: vec![], + } +} + +// --------------------------------------------------------------------------- +// Dispatch orchestrator +// --------------------------------------------------------------------------- + +/// Sends a push notification to every subscription registered for `anchor`. +/// +/// Called from [`crate::smtp::handle_smtp_request`] via `ic_cdk::spawn`. Any +/// subscription that returns 404/410 is removed from storage — those endpoints +/// will never succeed again. +#[cfg(not(test))] +pub async fn dispatch_push_for_anchor(recipient_key: &str) { + let anchor_number: AnchorNumber = match recipient_key.parse() { + Ok(n) => n, + Err(_) => return, + }; + + let vapid_key = match state::persistent_state(|ps| ps.vapid_key.clone()) { + Some(k) => k, + None => return, // No subscribers means no VAPID key exists yet. + }; + + let subscriptions = state::storage_borrow(|s| s.get_push_subscriptions(anchor_number)); + if subscriptions.is_empty() { + return; + } + + let mut gone_endpoints: Vec = Vec::new(); + for sub in &subscriptions { + match send_push_to_endpoint(&sub.endpoint, &vapid_key).await { + Ok(()) => {} + Err(PushError::SubscriptionGone) => { + gone_endpoints.push(sub.endpoint.clone()); + } + Err(PushError::Other(err)) => { + // Transient — keep the subscription and let the next email + // try again. No retry-now to bound cycle cost. + ic_cdk::println!("push dispatch failed: {err}"); + } + } + } + + if !gone_endpoints.is_empty() { + state::storage_borrow_mut(|s| { + for endpoint in &gone_endpoints { + s.remove_push_subscription(anchor_number, endpoint); + } + }); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + // Most of this module is gated on `#[cfg(not(test))]` because it depends + // on `ic_cdk::api::time()` and the management canister. Pure helpers like + // `extract_origin` are exercised indirectly via integration tests of the + // smtp flow. +} diff --git a/src/internet_identity_frontend/src/main.rs b/src/internet_identity_frontend/src/main.rs index 55b25b13d7..11cdf65628 100644 --- a/src/internet_identity_frontend/src/main.rs +++ b/src/internet_identity_frontend/src/main.rs @@ -270,6 +270,12 @@ fn get_asset_headers( /// font-src 'self': /// Allow fonts only from same origin /// +/// manifest-src 'self': +/// Allow the web app manifest (manifest.json) from same origin. Without +/// this, browsers would fall back to default-src 'none' and block the +/// element — which is needed for service worker +/// push notifications on the postbox page. +/// /// frame-ancestors 'self' : /// connect-src: /// In production, `connect-src 'self' https:` allows connections to the same origin @@ -331,6 +337,7 @@ fn get_content_security_policy( style-src 'self' 'unsafe-inline';\ style-src-elem 'self' 'unsafe-inline';\ font-src 'self';\ + manifest-src 'self';\ frame-ancestors {embedding_allowlist};\ frame-src {embedding_allowlist};" ); diff --git a/src/internet_identity_interface/src/internet_identity/types.rs b/src/internet_identity_interface/src/internet_identity/types.rs index 365689b22a..405fed7a35 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -23,6 +23,8 @@ mod api_v2; pub mod attributes; pub mod icrc3; pub mod openid; +pub mod push; +pub mod smtp; pub mod vc_mvp; // re-export v2 types without the ::v2 prefix, so that this crate can be restructured once v1 is removed diff --git a/src/internet_identity_interface/src/internet_identity/types/push.rs b/src/internet_identity_interface/src/internet_identity/types/push.rs new file mode 100644 index 0000000000..afa0d970d6 --- /dev/null +++ b/src/internet_identity_interface/src/internet_identity/types/push.rs @@ -0,0 +1,43 @@ +//! Types for Web Push notifications. + +use candid::{CandidType, Deserialize, Principal}; +use serde_bytes::ByteBuf; + +// --- Bounds --- + +/// Push subscription endpoints can be up to ~2048 bytes for RFC 8030 compliance. +pub const MAX_PUSH_ENDPOINT_BYTES: usize = 2_048; +/// Uncompressed P-256 public key: 65 bytes (0x04 || X || Y). +pub const PUSH_P256DH_KEY_BYTES: usize = 65; +/// Client auth secret per RFC 8291: 16 bytes. +pub const PUSH_AUTH_SECRET_BYTES: usize = 16; +/// Maximum number of push subscriptions stored per identity (anchor). +pub const MAX_PUSH_SUBSCRIPTIONS_PER_USER: usize = 10; + +// --- API types (Candid) --- + +/// A Web Push subscription as delivered by the browser's `PushManager.subscribe()`. +/// +/// `endpoint` is the push service URL the canister POSTs to. `p256dh` and `auth` +/// are reserved for a future payload-encrypted implementation (RFC 8291). In V1 +/// the canister sends empty-body pushes so these fields are accepted but unused. +#[derive(Clone, Debug, CandidType, Deserialize)] +pub struct PushSubscription { + pub endpoint: String, + pub p256dh: ByteBuf, + pub auth: ByteBuf, +} + +#[derive(Clone, Debug, CandidType, Deserialize)] +pub enum PushSubscribeError { + Unauthorized(Principal), + InvalidSubscription(String), + TooManySubscriptions, + InternalCanisterError(String), +} + +#[derive(Clone, Debug, CandidType, Deserialize)] +pub enum PushUnsubscribeError { + Unauthorized(Principal), + InternalCanisterError(String), +} diff --git a/src/internet_identity_interface/src/internet_identity/types/smtp.rs b/src/internet_identity_interface/src/internet_identity/types/smtp.rs new file mode 100644 index 0000000000..b084a9d6a3 --- /dev/null +++ b/src/internet_identity_interface/src/internet_identity/types/smtp.rs @@ -0,0 +1,536 @@ +use candid::{CandidType, Deserialize}; +use serde_bytes::ByteBuf; + +// --- Bounds --- + +pub const MAX_EMAIL_USER_BYTES: usize = 64; +pub const MAX_EMAIL_DOMAIN_BYTES: usize = 255; +pub const MAX_SUBJECT_BYTES: usize = 256; +pub const MAX_BODY_BYTES: usize = 5_000; +pub const MAX_HEADERS: usize = 30; +pub const MAX_HEADER_NAME_BYTES: usize = 256; +pub const MAX_HEADER_VALUE_BYTES: usize = 8_192; +pub const MAX_EMAILS_PER_USER: usize = 10; + +// --- SMTP error codes --- + +const SMTP_ERR_MAILBOX_UNAVAILABLE: u64 = 550; +const SMTP_ERR_SYNTAX_ERROR: u64 = 555; + +// --- API types (Candid) --- + +#[derive(Clone, Debug, CandidType, Deserialize)] +pub struct SmtpHeader { + pub name: String, + pub value: String, +} + +#[derive(Clone, Debug, CandidType, Deserialize)] +pub struct SmtpMessage { + pub headers: Vec, + pub body: ByteBuf, +} + +#[derive(Clone, Debug, CandidType, Deserialize)] +pub struct SmtpAddress { + pub user: String, + pub domain: String, +} + +#[derive(Clone, Debug, CandidType, Deserialize)] +pub struct SmtpEnvelope { + pub from: SmtpAddress, + pub to: SmtpAddress, +} + +#[derive(Clone, Debug, CandidType, Deserialize)] +pub struct SmtpRequest { + pub message: Option, + pub envelope: Option, + pub gateway_flags: Option>, +} + +#[derive(Clone, Debug, CandidType, Deserialize)] +pub struct SmtpRequestError { + pub code: u64, + pub message: String, +} + +#[derive(Clone, Debug, CandidType, Deserialize)] +pub enum SmtpResponse { + Ok {}, + Err(SmtpRequestError), +} + +// --- DKIM verification --- + +#[derive(Clone, Debug, CandidType, Deserialize)] +pub enum DkimCheckName { + DkimSignaturePresent, + SignatureParsed, + AlgorithmSupported, + RequiredHeadersSigned, + BodyHashValid, + PublicKeyFetched, + SignatureValid, +} + +#[derive(Clone, Debug, CandidType, Deserialize)] +pub enum DkimCheckStatus { + Pass, + Fail, + Skipped, +} + +#[derive(Clone, Debug, CandidType, Deserialize)] +pub struct DkimCheck { + pub name: DkimCheckName, + pub status: DkimCheckStatus, + pub detail: Option, +} + +#[derive(Clone, Debug, CandidType, Deserialize)] +pub enum DkimVerificationStatus { + Verified { checks: Vec }, + Unverified { checks: Vec }, + Pending, +} + +// --- Postbox query types --- + +#[derive(Clone, Debug, CandidType, Deserialize)] +pub struct PostboxEmail { + pub sender: String, + pub recipient: String, + pub subject: String, + pub body: String, + pub dkim_status: Option, +} + +// --- Validated internal types --- + +#[derive(Clone, Debug)] +pub struct ValidatedSmtpRequest { + pub anchor_number: u64, + pub sender: String, + pub recipient: String, + pub subject: String, + pub body: String, + pub headers: Vec, + pub raw_body: Vec, +} + +// --- Helpers --- + +fn smtp_err(code: u64, message: impl Into) -> SmtpResponse { + SmtpResponse::Err(SmtpRequestError { + code, + message: message.into(), + }) +} + +/// Renders an address as `user@domain` with both parts lowercased so it can +/// be used as a stable stable-map key. Envelope validation is already +/// case-insensitive (`eq_ignore_ascii_case`); without canonicalization the +/// same logical mailbox could be stored under multiple keys by varying +/// case, bypassing per-user pruning. +fn format_address(addr: &SmtpAddress) -> String { + format!( + "{}@{}", + addr.user.to_ascii_lowercase(), + addr.domain.to_ascii_lowercase() + ) +} + +fn validate_address_bounds(addr: &SmtpAddress, label: &str) -> Result<(), SmtpResponse> { + if addr.user.len() > MAX_EMAIL_USER_BYTES { + return Err(smtp_err( + SMTP_ERR_SYNTAX_ERROR, + format!("{label} user part exceeds {MAX_EMAIL_USER_BYTES} bytes"), + )); + } + if addr.domain.len() > MAX_EMAIL_DOMAIN_BYTES { + return Err(smtp_err( + SMTP_ERR_SYNTAX_ERROR, + format!("{label} domain exceeds {MAX_EMAIL_DOMAIN_BYTES} bytes"), + )); + } + Ok(()) +} + +fn validate_envelope(envelope: &SmtpEnvelope) -> Result { + validate_address_bounds(&envelope.from, "Sender")?; + validate_address_bounds(&envelope.to, "Recipient")?; + + let anchor_number = envelope.to.user.parse::().map_err(|_| { + smtp_err( + SMTP_ERR_MAILBOX_UNAVAILABLE, + "Recipient user must be a valid anchor number", + ) + })?; + + Ok(anchor_number) +} + +fn validate_message(message: &SmtpMessage) -> Result<(), SmtpResponse> { + if message.headers.len() > MAX_HEADERS { + return Err(smtp_err( + SMTP_ERR_SYNTAX_ERROR, + format!( + "Too many headers: {} (max {MAX_HEADERS})", + message.headers.len() + ), + )); + } + + for header in &message.headers { + if header.name.len() > MAX_HEADER_NAME_BYTES { + return Err(smtp_err( + SMTP_ERR_SYNTAX_ERROR, + format!("Header name exceeds {MAX_HEADER_NAME_BYTES} bytes"), + )); + } + if header.value.len() > MAX_HEADER_VALUE_BYTES { + return Err(smtp_err( + SMTP_ERR_SYNTAX_ERROR, + format!( + "Header '{}' value exceeds {MAX_HEADER_VALUE_BYTES} bytes", + header.name + ), + )); + } + } + + if message.body.len() > MAX_BODY_BYTES { + return Err(smtp_err( + SMTP_ERR_SYNTAX_ERROR, + format!( + "Body size {} exceeds limit of {MAX_BODY_BYTES} bytes", + message.body.len() + ), + )); + } + + Ok(()) +} + +fn extract_subject(headers: &[SmtpHeader]) -> String { + headers + .iter() + .find(|h| h.name.eq_ignore_ascii_case("subject")) + .map(|h| { + let mut s = h.value.clone(); + truncate_at_char_boundary(&mut s, MAX_SUBJECT_BYTES); + s + }) + .unwrap_or_default() +} + +/// `String::truncate` panics when the byte index is mid-codepoint. Subjects +/// with multi-byte UTF-8 characters right around [`MAX_SUBJECT_BYTES`] would +/// crash the canister on a crafted email. Clamp down to the previous char +/// boundary instead, which is safe even for arbitrary input. +fn truncate_at_char_boundary(s: &mut String, max_bytes: usize) { + if s.len() <= max_bytes { + return; + } + let mut end = max_bytes; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + s.truncate(end); +} + +// --- TryFrom --- + +impl TryFrom for ValidatedSmtpRequest { + type Error = SmtpResponse; + + fn try_from(request: SmtpRequest) -> Result { + let envelope = request + .envelope + .as_ref() + .ok_or_else(|| smtp_err(SMTP_ERR_SYNTAX_ERROR, "Missing envelope"))?; + + let anchor_number = validate_envelope(envelope)?; + + let message = request + .message + .as_ref() + .ok_or_else(|| smtp_err(SMTP_ERR_SYNTAX_ERROR, "Missing message"))?; + + validate_message(message)?; + + // `from_utf8_lossy` replaces each invalid byte with U+FFFD (3 bytes), + // so a body that passed the `MAX_BODY_BYTES` check above on byte + // length can still expand past the storage bound. Clamp the decoded + // string to `MAX_BODY_BYTES` on a char boundary so the insertion + // into stable storage cannot trap. + let mut body = String::from_utf8_lossy(&message.body).into_owned(); + truncate_at_char_boundary(&mut body, MAX_BODY_BYTES); + + Ok(ValidatedSmtpRequest { + anchor_number, + sender: format_address(&envelope.from), + recipient: format_address(&envelope.to), + subject: extract_subject(&message.headers), + headers: message.headers.clone(), + raw_body: message.body.to_vec(), + body, + }) + } +} + +/// Validates only the envelope portion of an SMTP request. +/// Used by `smtp_request_validate` when no message is present. +pub fn validate_envelope_only(request: &SmtpRequest) -> Result<(), SmtpResponse> { + let envelope = request + .envelope + .as_ref() + .ok_or_else(|| smtp_err(SMTP_ERR_SYNTAX_ERROR, "Missing envelope"))?; + + validate_envelope(envelope)?; + + if let Some(message) = &request.message { + validate_message(message)?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_bytes::ByteBuf; + + fn addr(user: &str, domain: &str) -> SmtpAddress { + SmtpAddress { + user: user.to_string(), + domain: domain.to_string(), + } + } + + fn envelope(anchor: u64) -> SmtpEnvelope { + SmtpEnvelope { + from: addr("sender", "example.com"), + to: addr(&anchor.to_string(), "id.ai"), + } + } + + fn ok_message() -> SmtpMessage { + SmtpMessage { + headers: vec![SmtpHeader { + name: "Subject".into(), + value: "hello".into(), + }], + body: ByteBuf::from(b"body".to_vec()), + } + } + + fn err_message(resp: &SmtpResponse) -> &str { + match resp { + SmtpResponse::Err(e) => &e.message, + SmtpResponse::Ok {} => panic!("expected Err, got Ok"), + } + } + + fn err_code(resp: &SmtpResponse) -> u64 { + match resp { + SmtpResponse::Err(e) => e.code, + SmtpResponse::Ok {} => panic!("expected Err, got Ok"), + } + } + + #[test] + fn missing_envelope_is_syntax_error() { + let req = SmtpRequest { + envelope: None, + message: Some(ok_message()), + gateway_flags: None, + }; + let resp = ValidatedSmtpRequest::try_from(req).unwrap_err(); + assert_eq!(err_code(&resp), SMTP_ERR_SYNTAX_ERROR); + assert!(err_message(&resp).contains("envelope")); + } + + #[test] + fn missing_message_is_syntax_error() { + let req = SmtpRequest { + envelope: Some(envelope(42)), + message: None, + gateway_flags: None, + }; + let resp = ValidatedSmtpRequest::try_from(req).unwrap_err(); + assert_eq!(err_code(&resp), SMTP_ERR_SYNTAX_ERROR); + assert!(err_message(&resp).contains("message")); + } + + #[test] + fn non_numeric_user_is_mailbox_unavailable() { + let req = SmtpRequest { + envelope: Some(SmtpEnvelope { + from: addr("sender", "example.com"), + to: addr("alice", "id.ai"), + }), + message: Some(ok_message()), + gateway_flags: None, + }; + let resp = ValidatedSmtpRequest::try_from(req).unwrap_err(); + assert_eq!(err_code(&resp), SMTP_ERR_MAILBOX_UNAVAILABLE); + } + + #[test] + fn too_many_headers_is_syntax_error() { + let headers: Vec = (0..MAX_HEADERS + 1) + .map(|i| SmtpHeader { + name: format!("X-Header-{i}"), + value: "v".into(), + }) + .collect(); + let req = SmtpRequest { + envelope: Some(envelope(1)), + message: Some(SmtpMessage { + headers, + body: ByteBuf::from(b"".to_vec()), + }), + gateway_flags: None, + }; + let resp = ValidatedSmtpRequest::try_from(req).unwrap_err(); + assert_eq!(err_code(&resp), SMTP_ERR_SYNTAX_ERROR); + assert!(err_message(&resp).contains("headers")); + } + + #[test] + fn oversize_header_name_is_syntax_error() { + let req = SmtpRequest { + envelope: Some(envelope(1)), + message: Some(SmtpMessage { + headers: vec![SmtpHeader { + name: "X".repeat(MAX_HEADER_NAME_BYTES + 1), + value: "v".into(), + }], + body: ByteBuf::from(b"".to_vec()), + }), + gateway_flags: None, + }; + let resp = ValidatedSmtpRequest::try_from(req).unwrap_err(); + assert_eq!(err_code(&resp), SMTP_ERR_SYNTAX_ERROR); + } + + #[test] + fn oversize_header_value_is_syntax_error() { + let req = SmtpRequest { + envelope: Some(envelope(1)), + message: Some(SmtpMessage { + headers: vec![SmtpHeader { + name: "X-Big".into(), + value: "a".repeat(MAX_HEADER_VALUE_BYTES + 1), + }], + body: ByteBuf::from(b"".to_vec()), + }), + gateway_flags: None, + }; + let resp = ValidatedSmtpRequest::try_from(req).unwrap_err(); + assert_eq!(err_code(&resp), SMTP_ERR_SYNTAX_ERROR); + } + + #[test] + fn oversize_body_is_syntax_error() { + let req = SmtpRequest { + envelope: Some(envelope(1)), + message: Some(SmtpMessage { + headers: vec![], + body: ByteBuf::from(vec![b'a'; MAX_BODY_BYTES + 1]), + }), + gateway_flags: None, + }; + let resp = ValidatedSmtpRequest::try_from(req).unwrap_err(); + assert_eq!(err_code(&resp), SMTP_ERR_SYNTAX_ERROR); + } + + #[test] + fn non_utf8_body_does_not_panic() { + // A byte sequence that is NOT valid UTF-8 but fits in MAX_BODY_BYTES. + // `from_utf8_lossy` will replace invalid bytes with U+FFFD (3 bytes + // each) so the decoded string would otherwise exceed the bound; the + // truncation helper must keep us safe. + let body = vec![0xFF_u8; MAX_BODY_BYTES]; + let req = SmtpRequest { + envelope: Some(envelope(7)), + message: Some(SmtpMessage { + headers: vec![], + body: ByteBuf::from(body), + }), + gateway_flags: None, + }; + let validated = ValidatedSmtpRequest::try_from(req).unwrap(); + assert!(validated.body.len() <= MAX_BODY_BYTES); + } + + #[test] + fn multibyte_subject_truncates_on_char_boundary() { + // Pad "а" (Cyrillic, 2 bytes) up to just past MAX_SUBJECT_BYTES and + // confirm `extract_subject` doesn't panic and returns a string + // shorter than the limit (possibly by 1 byte to land on a boundary). + // ~2 × MAX_SUBJECT_BYTES bytes total. + let subject: String = "а".repeat(MAX_SUBJECT_BYTES); + let req = SmtpRequest { + envelope: Some(envelope(1)), + message: Some(SmtpMessage { + headers: vec![SmtpHeader { + name: "Subject".into(), + value: subject, + }], + body: ByteBuf::from(b"".to_vec()), + }), + gateway_flags: None, + }; + let validated = ValidatedSmtpRequest::try_from(req).unwrap(); + assert!(validated.subject.len() <= MAX_SUBJECT_BYTES); + // A valid String: must be re-encodable as UTF-8 (implicit). + // Drop bytes if needed so we don't split a codepoint mid-sequence. + assert!(validated.subject.is_char_boundary(validated.subject.len())); + } + + #[test] + fn format_address_lowercases_both_parts() { + let lower = format_address(&addr("ALICE", "EXAMPLE.COM")); + assert_eq!(lower, "alice@example.com"); + } + + #[test] + fn accepted_message_roundtrips() { + let req = SmtpRequest { + envelope: Some(envelope(12345)), + message: Some(ok_message()), + gateway_flags: None, + }; + let validated = ValidatedSmtpRequest::try_from(req).unwrap(); + assert_eq!(validated.anchor_number, 12345); + assert_eq!(validated.recipient, "12345@id.ai"); + assert_eq!(validated.sender, "sender@example.com"); + assert_eq!(validated.subject, "hello"); + assert_eq!(validated.body, "body"); + } + + #[test] + fn validate_envelope_only_accepts_envelope_alone() { + let req = SmtpRequest { + envelope: Some(envelope(1)), + message: None, + gateway_flags: None, + }; + validate_envelope_only(&req).unwrap(); + } + + #[test] + fn validate_envelope_only_rejects_missing_envelope() { + let req = SmtpRequest { + envelope: None, + message: None, + gateway_flags: None, + }; + let err = validate_envelope_only(&req).unwrap_err(); + assert_eq!(err_code(&err), SMTP_ERR_SYNTAX_ERROR); + } +}