From 8549e3c4c6a059e8424dd91fb6ad60413c45af6b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:21:56 +0100 Subject: [PATCH 1/2] feat: translate the desktop app's client certificate picker and forget chosen certificates at sign-out and account switch --- desktop-policies/de-DE/Bulwark.adml | 2 +- desktop-policies/en-US/Bulwark.adml | 2 +- lib/desktop/__tests__/update-labels.test.ts | 21 ++++++++++ lib/desktop/certificate-labels.ts | 16 ++++++++ lib/desktop/keystore-grants.ts | 9 +++++ lib/desktop/languages.ts | 2 + lib/desktop/policy-schema.ts | 6 +-- lib/desktop/types.ts | 7 ++++ locales/ar/common.json | 8 ++++ locales/ca/common.json | 8 ++++ locales/cs/common.json | 8 ++++ locales/da/common.json | 8 ++++ locales/de/common.json | 8 ++++ locales/en/common.json | 8 ++++ locales/es/common.json | 8 ++++ locales/et/common.json | 8 ++++ locales/fa/common.json | 8 ++++ locales/fr/common.json | 8 ++++ locales/he/common.json | 8 ++++ locales/hu/common.json | 8 ++++ locales/it/common.json | 8 ++++ locales/ja/common.json | 8 ++++ locales/ko/common.json | 8 ++++ locales/lv/common.json | 8 ++++ locales/mn/common.json | 8 ++++ locales/nb/common.json | 8 ++++ locales/nl/common.json | 8 ++++ locales/pl/common.json | 8 ++++ locales/pt/common.json | 8 ++++ locales/ro/common.json | 8 ++++ locales/ru/common.json | 8 ++++ locales/sk/common.json | 8 ++++ locales/th/common.json | 8 ++++ locales/tr/common.json | 8 ++++ locales/uk/common.json | 8 ++++ locales/vi/common.json | 8 ++++ locales/zh-TW/common.json | 8 ++++ locales/zh/common.json | 8 ++++ .../__tests__/auth-store-logout-hooks.test.ts | 40 +++++++++++++++++++ stores/auth-store.ts | 5 ++- 40 files changed, 344 insertions(+), 6 deletions(-) create mode 100644 lib/desktop/certificate-labels.ts diff --git a/desktop-policies/de-DE/Bulwark.adml b/desktop-policies/de-DE/Bulwark.adml index fbacf62b3..273a8c7b9 100644 --- a/desktop-policies/de-DE/Bulwark.adml +++ b/desktop-policies/de-DE/Bulwark.adml @@ -49,7 +49,7 @@ Globale Tastenkombination für neue Nachricht Die Tastenkombination, die von überall aus eine neue Nachricht öffnet, etwa CommandOrControl+Alt+N. Geben Sie off ein, um sie auszuschalten. Server für die integrierte Anmeldung - Die Server, die die Windows-Anmeldung (Kerberos, NTLM) verwenden dürfen, durch Kommas getrennt, etwa *.example.com. Wird beim Neustart der App wirksam. + Die Server, die die Windows-Anmeldung (Kerberos, NTLM) verwenden dürfen, durch Kommas getrennt, etwa *.example.com. Ein von der Liste entfernter Server erhält sie sofort nicht mehr. Ein neuer braucht einen Neustart der App. Server für die delegierte Anmeldung Die Server, an die die App Kerberos-Anmeldedaten delegieren darf, durch Kommas getrennt. Wird beim Neustart der App wirksam. Clientzertifikate automatisch wählen diff --git a/desktop-policies/en-US/Bulwark.adml b/desktop-policies/en-US/Bulwark.adml index 5c73606f8..a80f0ddca 100644 --- a/desktop-policies/en-US/Bulwark.adml +++ b/desktop-policies/en-US/Bulwark.adml @@ -49,7 +49,7 @@ Global shortcut for a new message The key combination that opens a new message from anywhere, such as CommandOrControl+Alt+N. Enter off to turn the shortcut off. Servers for integrated sign-in - The servers that may use the Windows sign-in (Kerberos, NTLM), separated by commas, such as *.example.com. Takes effect when the app restarts. + The servers that may use the Windows sign-in (Kerberos, NTLM), separated by commas, such as *.example.com. A server taken off the list stops getting it at once. A new one needs the app to restart. Servers for delegated sign-in The servers the app may delegate Kerberos credentials to, separated by commas. Takes effect when the app restarts. Choose client certificates automatically diff --git a/lib/desktop/__tests__/update-labels.test.ts b/lib/desktop/__tests__/update-labels.test.ts index 095315c5c..864d20117 100644 --- a/lib/desktop/__tests__/update-labels.test.ts +++ b/lib/desktop/__tests__/update-labels.test.ts @@ -2,10 +2,31 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { shellLabels } from '../languages'; +import { certificateLabels } from '../certificate-labels'; import { updateLabels } from '../update-labels'; const en = JSON.parse(readFileSync(path.join(process.cwd(), 'locales/en/common.json'), 'utf8')).desktop_updates as Record; +describe('the certificate picker text the shell gets', () => { + const picker = JSON.parse(readFileSync(path.join(process.cwd(), 'locales/en/common.json'), 'utf8')).desktop_certificates as Record; + + it('keeps the placeholders the shell fills in itself', () => { + const labels = certificateLabels((key, values) => { + const text = picker[key.replace('desktop_certificates.', '')]; + return Object.entries(values ?? {}).reduce((out, [name, value]) => out.replaceAll(`{${name}}`, value), text); + }); + expect(labels.certificateTitle).toBe('{host} asks for a certificate'); + expect(labels.certificateRemember).toBe('Remember this for {host}'); + expect(labels.certificateIssued).toBe('Issued by {issuer}, valid until {date}'); + expect(Object.keys(labels).sort()).toEqual(['certificateDetail', 'certificateIssued', 'certificateMore', 'certificateNone', 'certificateRemember', 'certificateTitle']); + expect(Object.values(labels).every((text) => typeof text === 'string' && text.length > 0)).toBe(true); + }); + + it('goes out with the labels of the menus', () => { + expect(shellLabels((key) => key).certificateNone).toBe('desktop_certificates.picker_none'); + }); +}); + describe('the updater text the shell gets', () => { it('keeps the placeholders the shell fills in itself', () => { const labels = updateLabels((key, values) => { diff --git a/lib/desktop/certificate-labels.ts b/lib/desktop/certificate-labels.ts new file mode 100644 index 000000000..33f636716 --- /dev/null +++ b/lib/desktop/certificate-labels.ts @@ -0,0 +1,16 @@ +/** + * The client certificate picker's text in the interface language, by its keys in the shell's + * src/main/strings.ts. The shell fills in the {placeholders} itself, so they go through as + * they are. It goes out with the rest of the labels in shellLabels (languages.ts). + */ +export function certificateLabels(t: (key: string, values?: Record) => string): Record { + const keep = { host: '{host}', issuer: '{issuer}', date: '{date}' }; + return { + certificateTitle: t('desktop_certificates.picker_title', keep), + certificateDetail: t('desktop_certificates.picker_detail'), + certificateIssued: t('desktop_certificates.picker_issued', keep), + certificateRemember: t('desktop_certificates.picker_remember', keep), + certificateNone: t('desktop_certificates.picker_none'), + certificateMore: t('desktop_certificates.picker_more'), + }; +} diff --git a/lib/desktop/keystore-grants.ts b/lib/desktop/keystore-grants.ts index 2fc4cf7df..bed4f3d8f 100644 --- a/lib/desktop/keystore-grants.ts +++ b/lib/desktop/keystore-grants.ts @@ -10,6 +10,15 @@ export function clearKeystoreGrants(): void { void Promise.resolve(getDesktopBridge()?.keystore?.clearGrants?.()).catch(() => {}); } +/** + * Forgets which client certificate goes to which server, with what the app keeps of it in + * memory, so the next account at this computer gets asked. Nothing happens in a browser or on + * a shell without the call. + */ +export function clearDesktopCertificateChoices(): void { + void Promise.resolve(getDesktopBridge()?.certificates?.clearChoices?.()).catch(() => {}); +} + /** * Deletes the crash files on this computer. They can hold the mail of whoever was signed * in when the app crashed, so the next account at this computer doesn't get to send them. diff --git a/lib/desktop/languages.ts b/lib/desktop/languages.ts index aabfdeb01..b9cb38634 100644 --- a/lib/desktop/languages.ts +++ b/lib/desktop/languages.ts @@ -5,6 +5,7 @@ */ import { getDesktopBridge } from './bridge'; +import { certificateLabels } from './certificate-labels'; import { updateLabels } from './update-labels'; let ui = ''; @@ -42,6 +43,7 @@ export function noteComposerLanguage(key: string, language: string | null): void export function shellLabels(t: (key: string, values?: Record) => string): Record { return { ...updateLabels(t), + ...certificateLabels(t), menuFile: t('desktopUi.menu.file'), menuEdit: t('desktopUi.menu.edit'), menuView: t('desktopUi.menu.view'), diff --git a/lib/desktop/policy-schema.ts b/lib/desktop/policy-schema.ts index ee00aec1f..ee0e4cef5 100644 --- a/lib/desktop/policy-schema.ts +++ b/lib/desktop/policy-schema.ts @@ -337,8 +337,8 @@ export const POLICY_SCHEMA: Record = { label: { en: 'Servers', de: 'Server' }, title: { en: 'Servers for integrated sign-in', de: 'Server für die integrierte Anmeldung' }, description: { - en: 'The servers that may use the Windows sign-in (Kerberos, NTLM), separated by commas, such as *.example.com. Takes effect when the app restarts.', - de: 'Die Server, die die Windows-Anmeldung (Kerberos, NTLM) verwenden dürfen, durch Kommas getrennt, etwa *.example.com. Wird beim Neustart der App wirksam.', + en: 'The servers that may use the Windows sign-in (Kerberos, NTLM), separated by commas, such as *.example.com. A server taken off the list stops getting it at once. A new one needs the app to restart.', + de: 'Die Server, die die Windows-Anmeldung (Kerberos, NTLM) verwenden dürfen, durch Kommas getrennt, etwa *.example.com. Ein von der Liste entfernter Server erhält sie sofort nicht mehr. Ein neuer braucht einen Neustart der App.', }, }, AuthNegotiateDelegateAllowlist: { @@ -826,7 +826,7 @@ export const POLICY_CONSUMERS: Record = { TelemetryAllowed: { repo: 'shell', file: 'src/main/features/privacy.ts' }, CrashReportsAllowed: { repo: 'shell', file: 'src/main/features/privacy.ts' }, QuickComposeShortcut: { pendingConsumer: 'step 7, quick compose' }, - AutoSelectCertificateForUrls: { pendingConsumer: 'step 7, client certificates' }, + AutoSelectCertificateForUrls: { repo: 'shell', file: 'src/main/features/client-certificates.ts' }, KeystoreAllowedOrigins: { repo: 'shell', file: 'src/main/features/policies.ts' }, }; diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index a8331c347..a5a4073f9 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -323,6 +323,11 @@ export interface DesktopKeystoreBridge { clearGrants?: () => Promise; } +export interface DesktopCertificatesBridge { + /** Forgets the client certificates chosen for servers, for the next account. Only narrows. */ + clearChoices: () => Promise; +} + export interface DesktopPolicyBridge { /** What's in force now. Synchronous: a page can read it in its first render. */ get: () => PolicySnapshot; @@ -623,6 +628,8 @@ export interface BulwarkDesktopBridge { auth?: DesktopAuthBridge; plugins?: DesktopPluginsBridge; keystore?: DesktopKeystoreBridge; + /** Absent on older shells. */ + certificates?: DesktopCertificatesBridge; appLock?: DesktopAppLockBridge; localImport?: DesktopLocalImportBridge; privacy?: DesktopPrivacyBridge; diff --git a/locales/ar/common.json b/locales/ar/common.json index c759d9601..5abad4f9c 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6927,6 +6927,14 @@ "revoke": "سحب الإذن", "revoked": "تم سحب الإذن" }, + "desktop_certificates": { + "picker_title": "يطلب {host} شهادة", + "picker_detail": "يريد الخادم معرفة هويتك. الشهادة التي تختارها تذهب إلى هذا الخادم فقط. إذا كنت لا تعرف هذا الخادم، فلا ترسل شهادة.", + "picker_issued": "صادرة عن {issuer}، صالحة حتى {date}", + "picker_remember": "تذكّر هذا لـ {host}", + "picker_none": "عدم إرسال شهادة", + "picker_more": "المزيد من الشهادات…" + }, "desktopOs": { "section_title": "تطبيق سطح المكتب", "section_description": "طريقة عمل التطبيق مع حاسوبك.", diff --git a/locales/ca/common.json b/locales/ca/common.json index 9ca9c8032..c35c1fe9d 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6927,6 +6927,14 @@ "revoke": "Retira", "revoked": "Permís retirat" }, + "desktop_certificates": { + "picker_title": "{host} demana un certificat", + "picker_detail": "El servidor vol saber qui ets. El certificat que triïs només va a aquest servidor. Si no coneixes aquest servidor, no n'enviïs cap.", + "picker_issued": "Emès per {issuer}, vàlid fins al {date}", + "picker_remember": "Recorda-ho per a {host}", + "picker_none": "No enviïs cap certificat", + "picker_more": "Més certificats…" + }, "desktopOs": { "section_title": "Aplicació d'escriptori", "section_description": "Com funciona l'aplicació amb l'ordinador.", diff --git a/locales/cs/common.json b/locales/cs/common.json index 5585c00c0..202cb9c32 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6927,6 +6927,14 @@ "revoke": "Odvolat", "revoked": "Povolení bylo odvoláno" }, + "desktop_certificates": { + "picker_title": "{host} žádá o certifikát", + "picker_detail": "Server chce vědět, kdo jste. Certifikát, který vyberete, půjde jen na tento server. Pokud tento server neznáte, žádný neposílejte.", + "picker_issued": "Vydal {issuer}, platí do {date}", + "picker_remember": "Zapamatovat pro {host}", + "picker_none": "Neposílat certifikát", + "picker_more": "Další certifikáty…" + }, "desktopOs": { "section_title": "Desktopová aplikace", "section_description": "Jak aplikace spolupracuje s vaším počítačem.", diff --git a/locales/da/common.json b/locales/da/common.json index 9fa9629c6..ef0f8f8bb 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6927,6 +6927,14 @@ "revoke": "Træk tilbage", "revoked": "Tilladelsen er trukket tilbage" }, + "desktop_certificates": { + "picker_title": "{host} beder om et certifikat", + "picker_detail": "Serveren vil vide, hvem du er. Et certifikat, du vælger, sendes kun til denne server. Hvis du ikke kender serveren, så send ikke noget.", + "picker_issued": "Udstedt af {issuer}, gyldigt til {date}", + "picker_remember": "Husk dette for {host}", + "picker_none": "Send ikke et certifikat", + "picker_more": "Flere certifikater…" + }, "desktopOs": { "section_title": "Skrivebordsapp", "section_description": "Sådan arbejder appen sammen med din computer.", diff --git a/locales/de/common.json b/locales/de/common.json index d1bc0fa61..2dd1f9e00 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6927,6 +6927,14 @@ "revoke": "Zurücknehmen", "revoked": "Erlaubnis zurückgenommen" }, + "desktop_certificates": { + "picker_title": "{host} fragt nach einem Zertifikat", + "picker_detail": "Der Server möchte wissen, wer Sie sind. Ein Zertifikat, das Sie wählen, geht nur an diesen Server. Wenn Sie den Server nicht kennen, senden Sie keines.", + "picker_issued": "Ausgestellt von {issuer}, gültig bis {date}", + "picker_remember": "Für {host} merken", + "picker_none": "Kein Zertifikat senden", + "picker_more": "Weitere Zertifikate…" + }, "desktopOs": { "section_title": "Desktop-App", "section_description": "Wie die App mit Ihrem Computer zusammenarbeitet.", diff --git a/locales/en/common.json b/locales/en/common.json index b7b372d47..3d15da790 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6927,6 +6927,14 @@ "revoke": "Take back", "revoked": "Permission taken back" }, + "desktop_certificates": { + "picker_title": "{host} asks for a certificate", + "picker_detail": "The server wants to know who you are. A certificate you choose goes to this server only. If you don't know this server, don't send one.", + "picker_issued": "Issued by {issuer}, valid until {date}", + "picker_remember": "Remember this for {host}", + "picker_none": "Don't send a certificate", + "picker_more": "More certificates…" + }, "desktopOs": { "section_title": "Desktop app", "section_description": "How the app works with your computer.", diff --git a/locales/es/common.json b/locales/es/common.json index a6af887c6..05fb1f4a3 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6927,6 +6927,14 @@ "revoke": "Retirar", "revoked": "Permiso retirado" }, + "desktop_certificates": { + "picker_title": "{host} pide un certificado", + "picker_detail": "El servidor quiere saber quién eres. El certificado que elijas solo se envía a este servidor. Si no conoces este servidor, no envíes ninguno.", + "picker_issued": "Emitido por {issuer}, válido hasta {date}", + "picker_remember": "Recordar para {host}", + "picker_none": "No enviar ningún certificado", + "picker_more": "Más certificados…" + }, "desktopOs": { "section_title": "Aplicación de escritorio", "section_description": "Cómo funciona la aplicación con tu ordenador.", diff --git a/locales/et/common.json b/locales/et/common.json index 303262a33..699e94d13 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6927,6 +6927,14 @@ "revoke": "Võta tagasi", "revoked": "Luba võeti tagasi" }, + "desktop_certificates": { + "picker_title": "{host} küsib sertifikaati", + "picker_detail": "Server tahab teada, kes sa oled. Sinu valitud sertifikaat läheb ainult sellele serverile. Kui sa seda serverit ei tunne, ära seda saada.", + "picker_issued": "Väljaandja {issuer}, kehtib kuni {date}", + "picker_remember": "Jäta {host} jaoks meelde", + "picker_none": "Ära saada sertifikaati", + "picker_more": "Rohkem sertifikaate…" + }, "desktopOs": { "section_title": "Töölauarakendus", "section_description": "Kuidas rakendus sinu arvutiga koos töötab.", diff --git a/locales/fa/common.json b/locales/fa/common.json index 9a1f39794..2d76b38f9 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6927,6 +6927,14 @@ "revoke": "پس گرفتن", "revoked": "اجازه پس گرفته شد" }, + "desktop_certificates": { + "picker_title": "{host} یک گواهی می‌خواهد", + "picker_detail": "سرور می‌خواهد بداند شما چه کسی هستید. گواهی‌ای که انتخاب می‌کنید فقط به این سرور فرستاده می‌شود. اگر این سرور را نمی‌شناسید، گواهی نفرستید.", + "picker_issued": "صادرشده توسط {issuer}، معتبر تا {date}", + "picker_remember": "برای {host} به خاطر بسپار", + "picker_none": "گواهی نفرست", + "picker_more": "گواهی‌های بیشتر…" + }, "desktopOs": { "section_title": "برنامه دسکتاپ", "section_description": "نحوه کار برنامه با رایانه شما.", diff --git a/locales/fr/common.json b/locales/fr/common.json index fd43c5da3..f38a0553b 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6927,6 +6927,14 @@ "revoke": "Retirer", "revoked": "Autorisation retirée" }, + "desktop_certificates": { + "picker_title": "{host} demande un certificat", + "picker_detail": "Le serveur veut savoir qui vous êtes. Le certificat que vous choisissez n'est envoyé qu'à ce serveur. Si vous ne connaissez pas ce serveur, n'en envoyez pas.", + "picker_issued": "Émis par {issuer}, valable jusqu'au {date}", + "picker_remember": "Mémoriser pour {host}", + "picker_none": "Ne pas envoyer de certificat", + "picker_more": "Plus de certificats…" + }, "desktopOs": { "section_title": "Application de bureau", "section_description": "Comment l'application fonctionne avec votre ordinateur.", diff --git a/locales/he/common.json b/locales/he/common.json index 10bb0aacf..f9d0570bf 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6928,6 +6928,14 @@ "revoke": "ביטול", "revoked": "ההרשאה בוטלה" }, + "desktop_certificates": { + "picker_title": "{host} מבקש אישור", + "picker_detail": "השרת רוצה לדעת מי אתם. האישור שתבחרו יישלח לשרת הזה בלבד. אם אינכם מכירים את השרת הזה, אל תשלחו אישור.", + "picker_issued": "הונפק על ידי {issuer}, בתוקף עד {date}", + "picker_remember": "לזכור עבור {host}", + "picker_none": "לא לשלוח אישור", + "picker_more": "אישורים נוספים…" + }, "desktopOs": { "section_title": "יישום שולחן עבודה", "section_description": "איך היישום עובד עם המחשב שלך.", diff --git a/locales/hu/common.json b/locales/hu/common.json index 50702f8fb..5ce557f83 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6927,6 +6927,14 @@ "revoke": "Visszavonás", "revoked": "Engedély visszavonva" }, + "desktop_certificates": { + "picker_title": "A(z) {host} tanúsítványt kér", + "picker_detail": "A kiszolgáló tudni szeretné, ki vagy. A kiválasztott tanúsítvány csak ehhez a kiszolgálóhoz kerül. Ha nem ismered ezt a kiszolgálót, ne küldj tanúsítványt.", + "picker_issued": "Kiállító: {issuer}, érvényes eddig: {date}", + "picker_remember": "Megjegyzés ehhez: {host}", + "picker_none": "Ne küldjön tanúsítványt", + "picker_more": "További tanúsítványok…" + }, "desktopOs": { "section_title": "Asztali alkalmazás", "section_description": "Hogyan működik az alkalmazás a számítógépével.", diff --git a/locales/it/common.json b/locales/it/common.json index 7ff4479d1..c8cdd3d83 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6927,6 +6927,14 @@ "revoke": "Ritira", "revoked": "Permesso ritirato" }, + "desktop_certificates": { + "picker_title": "{host} chiede un certificato", + "picker_detail": "Il server vuole sapere chi sei. Il certificato che scegli va solo a questo server. Se non conosci questo server, non inviarne nessuno.", + "picker_issued": "Emesso da {issuer}, valido fino al {date}", + "picker_remember": "Ricorda per {host}", + "picker_none": "Non inviare un certificato", + "picker_more": "Altri certificati…" + }, "desktopOs": { "section_title": "App desktop", "section_description": "Come l'app funziona con il tuo computer.", diff --git a/locales/ja/common.json b/locales/ja/common.json index 3eda5c005..e126d05ef 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6927,6 +6927,14 @@ "revoke": "取り消す", "revoked": "許可を取り消しました" }, + "desktop_certificates": { + "picker_title": "{host} が証明書を求めています", + "picker_detail": "サーバーがあなたを確認しようとしています。選んだ証明書はこのサーバーにだけ送られます。このサーバーに心当たりがない場合は送らないでください。", + "picker_issued": "発行者: {issuer}、有効期限: {date}", + "picker_remember": "{host} について記憶する", + "picker_none": "証明書を送らない", + "picker_more": "他の証明書…" + }, "desktopOs": { "section_title": "デスクトップアプリ", "section_description": "アプリとパソコンの連携方法です。", diff --git a/locales/ko/common.json b/locales/ko/common.json index 355bdbb3f..11bf2754c 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6927,6 +6927,14 @@ "revoke": "취소", "revoked": "허용을 취소했습니다" }, + "desktop_certificates": { + "picker_title": "{host}에서 인증서를 요청합니다", + "picker_detail": "서버에서 사용자를 확인하려고 합니다. 선택한 인증서는 이 서버로만 전송됩니다. 모르는 서버라면 보내지 마세요.", + "picker_issued": "발급자: {issuer}, 유효 기간: {date}까지", + "picker_remember": "{host}에 대해 기억", + "picker_none": "인증서 보내지 않기", + "picker_more": "다른 인증서…" + }, "desktopOs": { "section_title": "데스크톱 앱", "section_description": "앱이 컴퓨터와 함께 작동하는 방식입니다.", diff --git a/locales/lv/common.json b/locales/lv/common.json index 2f5d9e297..6c27a2343 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6927,6 +6927,14 @@ "revoke": "Atsaukt", "revoked": "Atļauja atsaukta" }, + "desktop_certificates": { + "picker_title": "{host} prasa sertifikātu", + "picker_detail": "Serveris vēlas zināt, kas jūs esat. Jūsu izvēlētais sertifikāts tiks nosūtīts tikai šim serverim. Ja nepazīstat šo serveri, nesūtiet to.", + "picker_issued": "Izdevējs: {issuer}, derīgs līdz {date}", + "picker_remember": "Atcerēties vietnei {host}", + "picker_none": "Nesūtīt sertifikātu", + "picker_more": "Vairāk sertifikātu…" + }, "desktopOs": { "section_title": "Darbvirsmas lietotne", "section_description": "Kā lietotne darbojas ar jūsu datoru.", diff --git a/locales/mn/common.json b/locales/mn/common.json index cf0de6ec1..cd9d7c99a 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6927,6 +6927,14 @@ "revoke": "Буцааж авах", "revoked": "Зөвшөөрлийг буцааж авлаа" }, + "desktop_certificates": { + "picker_title": "{host} гэрчилгээ хүсэж байна", + "picker_detail": "Сервер таныг хэн болохыг мэдэхийг хүсэж байна. Таны сонгосон гэрчилгээ зөвхөн энэ сервер рүү очно. Хэрэв та энэ серверийг мэдэхгүй бол бүү илгээ.", + "picker_issued": "Олгосон: {issuer}, хүчинтэй хугацаа: {date} хүртэл", + "picker_remember": "{host}-д санах", + "picker_none": "Гэрчилгээ илгээхгүй", + "picker_more": "Бусад гэрчилгээ…" + }, "desktopOs": { "section_title": "Ширээний апп", "section_description": "Апп таны компьютертэй хэрхэн ажиллах.", diff --git a/locales/nb/common.json b/locales/nb/common.json index c434676eb..925109903 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6927,6 +6927,14 @@ "revoke": "Trekk tilbake", "revoked": "Tillatelsen er trukket tilbake" }, + "desktop_certificates": { + "picker_title": "{host} ber om et sertifikat", + "picker_detail": "Serveren vil vite hvem du er. Et sertifikat du velger, går bare til denne serveren. Hvis du ikke kjenner serveren, ikke send noe.", + "picker_issued": "Utstedt av {issuer}, gyldig til {date}", + "picker_remember": "Husk dette for {host}", + "picker_none": "Ikke send et sertifikat", + "picker_more": "Flere sertifikater…" + }, "desktopOs": { "section_title": "Skrivebordsapp", "section_description": "Slik samarbeider appen med datamaskinen din.", diff --git a/locales/nl/common.json b/locales/nl/common.json index 3afb0250b..0b90b7aea 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6927,6 +6927,14 @@ "revoke": "Intrekken", "revoked": "Toestemming ingetrokken" }, + "desktop_certificates": { + "picker_title": "{host} vraagt om een certificaat", + "picker_detail": "De server wil weten wie je bent. Een certificaat dat je kiest, gaat alleen naar deze server. Ken je deze server niet, stuur er dan geen.", + "picker_issued": "Uitgegeven door {issuer}, geldig tot {date}", + "picker_remember": "Onthouden voor {host}", + "picker_none": "Geen certificaat sturen", + "picker_more": "Meer certificaten…" + }, "desktopOs": { "section_title": "Desktop-app", "section_description": "Hoe de app samenwerkt met je computer.", diff --git a/locales/pl/common.json b/locales/pl/common.json index 003c9b529..c05fe2431 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6927,6 +6927,14 @@ "revoke": "Cofnij", "revoked": "Zgoda została cofnięta" }, + "desktop_certificates": { + "picker_title": "{host} prosi o certyfikat", + "picker_detail": "Serwer chce wiedzieć, kim jesteś. Wybrany certyfikat trafi tylko do tego serwera. Jeśli nie znasz tego serwera, nie wysyłaj go.", + "picker_issued": "Wystawca: {issuer}, ważny do {date}", + "picker_remember": "Zapamiętaj dla {host}", + "picker_none": "Nie wysyłaj certyfikatu", + "picker_more": "Więcej certyfikatów…" + }, "desktopOs": { "section_title": "Aplikacja na komputer", "section_description": "Jak aplikacja współpracuje z Twoim komputerem.", diff --git a/locales/pt/common.json b/locales/pt/common.json index 4cfe8b1e4..81e7374e5 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6927,6 +6927,14 @@ "revoke": "Retirar", "revoked": "Permissão retirada" }, + "desktop_certificates": { + "picker_title": "{host} pede um certificado", + "picker_detail": "O servidor quer saber quem é. O certificado que escolher vai apenas para este servidor. Se não conhece este servidor, não envie nenhum.", + "picker_issued": "Emitido por {issuer}, válido até {date}", + "picker_remember": "Lembrar para {host}", + "picker_none": "Não enviar um certificado", + "picker_more": "Mais certificados…" + }, "desktopOs": { "section_title": "Aplicação de ambiente de trabalho", "section_description": "Como a aplicação funciona com o seu computador.", diff --git a/locales/ro/common.json b/locales/ro/common.json index 572f9b856..ece1c90ab 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6927,6 +6927,14 @@ "revoke": "Retrage", "revoked": "Permisiune retrasă" }, + "desktop_certificates": { + "picker_title": "{host} cere un certificat", + "picker_detail": "Serverul vrea să știe cine ești. Certificatul pe care îl alegi merge doar la acest server. Dacă nu cunoști acest server, nu trimite niciunul.", + "picker_issued": "Emis de {issuer}, valabil până la {date}", + "picker_remember": "Reține pentru {host}", + "picker_none": "Nu trimite un certificat", + "picker_more": "Mai multe certificate…" + }, "desktopOs": { "section_title": "Aplicație desktop", "section_description": "Cum funcționează aplicația cu computerul tău.", diff --git a/locales/ru/common.json b/locales/ru/common.json index 2fdb08106..a7a035252 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6927,6 +6927,14 @@ "revoke": "Отозвать", "revoked": "Разрешение отозвано" }, + "desktop_certificates": { + "picker_title": "{host} запрашивает сертификат", + "picker_detail": "Сервер хочет знать, кто вы. Выбранный сертификат будет отправлен только на этот сервер. Если вы не знаете этот сервер, не отправляйте его.", + "picker_issued": "Выдан: {issuer}, действует до {date}", + "picker_remember": "Запомнить для {host}", + "picker_none": "Не отправлять сертификат", + "picker_more": "Другие сертификаты…" + }, "desktopOs": { "section_title": "Настольное приложение", "section_description": "Как приложение работает с вашим компьютером.", diff --git a/locales/sk/common.json b/locales/sk/common.json index d8a42ccfb..06aa9e667 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6927,6 +6927,14 @@ "revoke": "Odvolať", "revoked": "Povolenie bolo odvolané" }, + "desktop_certificates": { + "picker_title": "{host} žiada o certifikát", + "picker_detail": "Server chce vedieť, kto ste. Certifikát, ktorý vyberiete, pôjde len na tento server. Ak tento server nepoznáte, žiadny neposielajte.", + "picker_issued": "Vydal {issuer}, platí do {date}", + "picker_remember": "Zapamätať pre {host}", + "picker_none": "Neposielať certifikát", + "picker_more": "Ďalšie certifikáty…" + }, "desktopOs": { "section_title": "Počítačová aplikácia", "section_description": "Ako aplikácia spolupracuje s vaším počítačom.", diff --git a/locales/th/common.json b/locales/th/common.json index af4128993..c8c3336ac 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6935,6 +6935,14 @@ "revoke": "ถอน", "revoked": "ถอนการอนุญาตแล้ว" }, + "desktop_certificates": { + "picker_title": "{host} ขอใบรับรอง", + "picker_detail": "เซิร์ฟเวอร์ต้องการทราบว่าคุณคือใคร ใบรับรองที่คุณเลือกจะส่งไปยังเซิร์ฟเวอร์นี้เท่านั้น หากคุณไม่รู้จักเซิร์ฟเวอร์นี้ อย่าส่ง", + "picker_issued": "ออกโดย {issuer} ใช้ได้ถึง {date}", + "picker_remember": "จดจำสำหรับ {host}", + "picker_none": "ไม่ส่งใบรับรอง", + "picker_more": "ใบรับรองเพิ่มเติม…" + }, "desktopOs": { "section_title": "แอปเดสก์ท็อป", "section_description": "วิธีที่แอปทำงานกับคอมพิวเตอร์ของคุณ", diff --git a/locales/tr/common.json b/locales/tr/common.json index c74a4ae59..e79dd9a90 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6927,6 +6927,14 @@ "revoke": "Geri al", "revoked": "İzin geri alındı" }, + "desktop_certificates": { + "picker_title": "{host} bir sertifika istiyor", + "picker_detail": "Sunucu kim olduğunuzu bilmek istiyor. Seçtiğiniz sertifika yalnızca bu sunucuya gider. Bu sunucuyu tanımıyorsanız göndermeyin.", + "picker_issued": "Veren: {issuer}, geçerlilik: {date} tarihine kadar", + "picker_remember": "{host} için hatırla", + "picker_none": "Sertifika gönderme", + "picker_more": "Daha fazla sertifika…" + }, "desktopOs": { "section_title": "Masaüstü uygulaması", "section_description": "Uygulamanın bilgisayarınızla nasıl çalıştığı.", diff --git a/locales/uk/common.json b/locales/uk/common.json index 43227c05e..a8ac3227a 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6927,6 +6927,14 @@ "revoke": "Відкликати", "revoked": "Дозвіл відкликано" }, + "desktop_certificates": { + "picker_title": "{host} запитує сертифікат", + "picker_detail": "Сервер хоче знати, хто ви. Вибраний сертифікат буде надіслано лише на цей сервер. Якщо ви не знаєте цей сервер, не надсилайте його.", + "picker_issued": "Видав {issuer}, дійсний до {date}", + "picker_remember": "Запам'ятати для {host}", + "picker_none": "Не надсилати сертифікат", + "picker_more": "Інші сертифікати…" + }, "desktopOs": { "section_title": "Настільний застосунок", "section_description": "Як застосунок працює з вашим комп’ютером.", diff --git a/locales/vi/common.json b/locales/vi/common.json index 8c6ef8e6f..a61f5627e 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6939,6 +6939,14 @@ "revoke": "Thu hồi", "revoked": "Đã thu hồi quyền" }, + "desktop_certificates": { + "picker_title": "{host} yêu cầu một chứng chỉ", + "picker_detail": "Máy chủ muốn biết bạn là ai. Chứng chỉ bạn chọn chỉ được gửi đến máy chủ này. Nếu bạn không biết máy chủ này, đừng gửi.", + "picker_issued": "Do {issuer} cấp, có hiệu lực đến {date}", + "picker_remember": "Ghi nhớ cho {host}", + "picker_none": "Không gửi chứng chỉ", + "picker_more": "Thêm chứng chỉ…" + }, "desktopOs": { "section_title": "Ứng dụng máy tính", "section_description": "Cách ứng dụng hoạt động với máy tính của bạn.", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 1028578b1..f1e8bfe38 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6927,6 +6927,14 @@ "revoke": "收回", "revoked": "已收回許可" }, + "desktop_certificates": { + "picker_title": "{host} 要求提供憑證", + "picker_detail": "伺服器想確認你的身分。你選擇的憑證只會傳送到此伺服器。如果你不認識此伺服器,請不要傳送。", + "picker_issued": "發行者:{issuer},有效期限至 {date}", + "picker_remember": "為 {host} 記住此選擇", + "picker_none": "不傳送憑證", + "picker_more": "更多憑證…" + }, "desktopOs": { "section_title": "桌面應用程式", "section_description": "應用程式如何與您的電腦配合。", diff --git a/locales/zh/common.json b/locales/zh/common.json index 641f7a2cf..d4cb13646 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6927,6 +6927,14 @@ "revoke": "收回", "revoked": "已收回许可" }, + "desktop_certificates": { + "picker_title": "{host} 要求提供证书", + "picker_detail": "服务器想确认你的身份。你选择的证书只会发送到此服务器。如果你不认识此服务器,请不要发送。", + "picker_issued": "颁发者:{issuer},有效期至 {date}", + "picker_remember": "为 {host} 记住此选择", + "picker_none": "不发送证书", + "picker_more": "更多证书…" + }, "desktopOs": { "section_title": "桌面应用", "section_description": "应用如何与你的电脑配合。", diff --git a/stores/__tests__/auth-store-logout-hooks.test.ts b/stores/__tests__/auth-store-logout-hooks.test.ts index 663d0d575..3e5e53f8d 100644 --- a/stores/__tests__/auth-store-logout-hooks.test.ts +++ b/stores/__tests__/auth-store-logout-hooks.test.ts @@ -73,6 +73,46 @@ describe('sign-out with plugins', () => { }); }); + describe('client certificates chosen for servers', () => { + afterEach(() => { delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; }); + const withBridge = (clearChoices?: () => Promise) => { + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, certificates: { clearChoices } }; + }; + + it('are forgotten at sign-out', async () => { + const clearChoices = vi.fn().mockResolvedValue(undefined); + withBridge(clearChoices); + await useAuthStore.getState().logout(); + expect(clearChoices).toHaveBeenCalledTimes(1); + }); + + it('when all accounts sign out', async () => { + const clearChoices = vi.fn().mockResolvedValue(undefined); + withBridge(clearChoices); + await useAuthStore.getState().logoutAll(); + expect(clearChoices).toHaveBeenCalled(); + }); + + it('and when switching to another account', () => { + const clearChoices = vi.fn().mockResolvedValue(undefined); + withBridge(clearChoices); + useAuthStore.setState({ isAuthenticated: true, activeAccountId: 'a1' }); + expect(clearChoices).not.toHaveBeenCalled(); + useAuthStore.setState({ activeAccountId: 'a2' }); + expect(clearChoices).toHaveBeenCalledTimes(1); + }); + + it('signs out all the same on a shell without the call, or one that fails', async () => { + withBridge(undefined); + await useAuthStore.getState().logout(); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + useAuthStore.setState({ isAuthenticated: true }); + withBridge(vi.fn().mockRejectedValue(new Error('gone'))); + await useAuthStore.getState().logout(); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + }); + }); + describe('keys in the OS store', () => { afterEach(() => { delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; }); const withBridge = (clearGrants?: () => Promise) => { diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 2250c9ec9..0fd01dfd7 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -18,7 +18,7 @@ import { generateAccountId, MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; import { replaceWindowLocation, getPathPrefix, getLocaleFromPath, apiFetch } from '@/lib/browser-navigation'; import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; import { getDesktopBridge } from '@/lib/desktop/bridge'; -import { clearDesktopCrashFiles, clearKeystoreGrants } from '@/lib/desktop/keystore-grants'; +import { clearDesktopCertificateChoices, clearDesktopCrashFiles, clearKeystoreGrants } from '@/lib/desktop/keystore-grants'; import { DESKTOP_CLIENT_ID_KEY } from '@/lib/desktop/browser-sign-in'; import { isEmbedded, notifyParent } from '@/lib/iframe-bridge'; import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll, forgetAccountRows } from '@/lib/account-state-manager'; @@ -2544,6 +2544,7 @@ export const useAuthStore = create()( useSettingsStore.getState().disableSync(); clearKeystoreGrants(); + clearDesktopCertificateChoices(); clearDesktopCrashFiles(); void authHooks.onAfterLogout.emit({ accountId: accountId ?? 'all', @@ -2725,6 +2726,7 @@ export const useAuthStore = create()( } clearKeystoreGrants(); + clearDesktopCertificateChoices(); clearDesktopCrashFiles(); void authHooks.onAfterLogout.emit({ accountId: 'all' @@ -3874,6 +3876,7 @@ useAuthStore.subscribe((state, prev) => { if (ready && !wasReady) void authHooks.onAuthReady.emit({ accountId: state.activeAccountId }); if (state.activeAccountId && prev.activeAccountId && state.activeAccountId !== prev.activeAccountId) { clearKeystoreGrants(); + clearDesktopCertificateChoices(); clearDesktopCrashFiles(); void authHooks.onAccountSwitch.emit({ accountId: state.activeAccountId, previousAccountId: prev.activeAccountId }); } From 889f6ffbd1b7dde106b5d48df4b5ce62b2f7d752 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:56:09 +0100 Subject: [PATCH 2/2] fix: fail requests under way when the desktop app closes its connections, and forget client certificates per account --- lib/__tests__/jmap-connections-reset.test.ts | 129 ++++++++++++++++++ lib/desktop/connections-reset.ts | 26 ++++ lib/desktop/keystore-grants.ts | 16 ++- lib/desktop/types.ts | 12 +- lib/jmap/client.ts | 32 ++++- .../__tests__/auth-store-logout-hooks.test.ts | 28 +++- .../auth-store-token-renewal-timeout.test.ts | 118 ++++++++++++++++ stores/auth-store.ts | 37 ++++- 8 files changed, 375 insertions(+), 23 deletions(-) create mode 100644 lib/__tests__/jmap-connections-reset.test.ts create mode 100644 lib/desktop/connections-reset.ts create mode 100644 stores/__tests__/auth-store-token-renewal-timeout.test.ts diff --git a/lib/__tests__/jmap-connections-reset.test.ts b/lib/__tests__/jmap-connections-reset.test.ts new file mode 100644 index 000000000..e2de4acdc --- /dev/null +++ b/lib/__tests__/jmap-connections-reset.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { JMAPClient, RequestTimeoutError } from '../jmap/client'; +import { UploadXHR, uploadClient } from './helpers/upload-xhr'; + +/** + * The desktop app closed its connections under the page. A request under way then never settles + * by itself, so all that ends it is being told. + */ + +const session = { + capabilities: { 'urn:ietf:params:jmap:core': {} }, + accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: {} } }, + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' }, + apiUrl: 'https://mail.example.com/jmap/api', + downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}', + uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/', + eventSourceUrl: 'https://mail.example.com/jmap/eventsource', +}; + +let reset: () => void; + +function withBridge(): void { + const listeners = new Set<() => void>(); + reset = () => { + for (const listener of listeners) listener(); + }; + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { + version: 1, + certificates: { + onConnectionsReset: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + }; +} + +/** Takes the request, and nothing ever comes back. Only an abort ends it. */ +const silent = (_url: unknown, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason ?? new DOMException('aborted', 'AbortError'))); + }); + +/** The one all of the client's calls go through. */ +const requestOf = (client: JMAPClient) => (client as unknown as { request(calls: unknown[]): Promise }).request.bind(client); + +async function connectedClient(): Promise { + const client = new JMAPClient('https://mail.example.com', 'alice', 'secret'); + vi.mocked(fetch).mockImplementationOnce(async () => new Response(JSON.stringify(session), { status: 200, headers: { 'Content-Type': 'application/json' } })); + await client.connect(); + return client; +} + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); +}); + +afterEach(() => { + delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('when the desktop app closes its connections', () => { + it('a send under way fails as "may have sent", at once, and isn\'t sent again', async () => { + withBridge(); + const client = await connectedClient(); + vi.mocked(fetch).mockClear(); + vi.mocked(fetch).mockImplementation(silent); + const send = requestOf(client)([['EmailSubmission/set', { accountId: 'acct-1', create: { s: {} } }, '0']]); + await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + reset(); + await expect(send).rejects.toBeInstanceOf(RequestTimeoutError); + await new Promise((resolve) => setTimeout(resolve, 1200)); + expect(fetch, 'the client retries other failures of a read after 1 s: never this').toHaveBeenCalledTimes(1); + }); + + it('a read under way fails too, and nothing waits for its deadline', async () => { + withBridge(); + const client = await connectedClient(); + vi.mocked(fetch).mockClear(); + vi.mocked(fetch).mockImplementation(silent); + const read = requestOf(client)([['Mailbox/get', { accountId: 'acct-1' }, '0']]); + await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + reset(); + await expect(read).rejects.toBeInstanceOf(RequestTimeoutError); + }); + + it('a body still being read is caught as well', async () => { + withBridge(); + const client = await connectedClient(); + vi.mocked(fetch).mockClear(); + let signal: AbortSignal | undefined; + vi.mocked(fetch).mockImplementation(async (_url, init) => { + signal = init?.signal ?? undefined; + return new Response('{}', { status: 200 }); + }); + await requestOf(client)([['Mailbox/get', { accountId: 'acct-1' }, '0']]).catch(() => {}); + expect(signal?.aborted).toBe(false); + reset(); + expect(signal?.reason).toBeInstanceOf(RequestTimeoutError); + }); + + it('an upload under way fails the same way, not five minutes later', async () => { + withBridge(); + UploadXHR.instances = []; + vi.stubGlobal('XMLHttpRequest', UploadXHR); + const upload = uploadClient().uploadBlob(new File(['x'], 'a.txt', { type: 'text/plain' }), { onProgress: () => {} }); + await vi.waitFor(() => expect(UploadXHR.instances).toHaveLength(1)); + reset(); + await expect(upload).rejects.toBeInstanceOf(RequestTimeoutError); + expect(UploadXHR.instances[0].abort).toHaveBeenCalledOnce(); + expect(UploadXHR.instances).toHaveLength(1); + }); + + it('nothing changes in a browser, where a deadline is all there is', async () => { + const client = await connectedClient(); + vi.mocked(fetch).mockClear(); + vi.useFakeTimers(); + vi.mocked(fetch).mockImplementation(silent); + const read = requestOf(client)([['Mailbox/get', { accountId: 'acct-1' }, '0']]); + const failed = expect(read).rejects.toBeInstanceOf(RequestTimeoutError); + await vi.advanceTimersByTimeAsync(0); + expect(vi.getTimerCount(), 'only the deadline').toBe(1); + await vi.advanceTimersByTimeAsync(10 * 60_000); + await failed; + }); +}); diff --git a/lib/desktop/connections-reset.ts b/lib/desktop/connections-reset.ts new file mode 100644 index 000000000..71dee5752 --- /dev/null +++ b/lib/desktop/connections-reset.ts @@ -0,0 +1,26 @@ +import { getDesktopBridge } from './bridge'; + +/** + * The desktop app's connections were closed under the page: at the last sign-out with a client + * certificate in use, when a policy changed, or when Chromium's network process crashed. A + * request under way then never settles, as Electron's request proxy doesn't pass the loss on. So + * main says so, and whoever holds a request gives up on it. Nothing happens in a browser. + */ + +let wiredTo: unknown = null; +const listeners = new Set<() => void>(); + +/** Null where there's nothing to listen to: in a browser, or on an older shell. */ +export function onConnectionsReset(listener: () => void): (() => void) | null { + const bridge = getDesktopBridge(); + const subscribe = bridge?.certificates?.onConnectionsReset; + if (!subscribe) return null; + if (wiredTo !== bridge) { + wiredTo = bridge; + subscribe(() => { + for (const each of [...listeners]) each(); + }); + } + listeners.add(listener); + return () => listeners.delete(listener); +} diff --git a/lib/desktop/keystore-grants.ts b/lib/desktop/keystore-grants.ts index bed4f3d8f..a16f2baf3 100644 --- a/lib/desktop/keystore-grants.ts +++ b/lib/desktop/keystore-grants.ts @@ -11,12 +11,18 @@ export function clearKeystoreGrants(): void { } /** - * Forgets which client certificate goes to which server, with what the app keeps of it in - * memory, so the next account at this computer gets asked. Nothing happens in a browser or on - * a shell without the call. + * Forgets which client certificate goes to which server. An account's own sign-out takes what + * was chosen under it. The last sign-out takes everything, with what the app keeps of it in + * memory, so the next person at this computer gets asked. That restarts the app's connections, + * so an account switch never comes here. Nothing happens in a browser or on a shell without the call. */ -export function clearDesktopCertificateChoices(): void { - void Promise.resolve(getDesktopBridge()?.certificates?.clearChoices?.()).catch(() => {}); +export function clearDesktopCertificateChoices(scope: { account?: string; last?: boolean }): void { + void Promise.resolve(getDesktopBridge()?.certificates?.clearChoices?.(scope)).catch(() => {}); +} + +/** Which account a certificate chosen from now on belongs to. */ +export function noteDesktopCertificateAccount(account: string | null): void { + void Promise.resolve(getDesktopBridge()?.certificates?.setAccount?.(account)).catch(() => {}); } /** diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index a5a4073f9..65a51d161 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -324,8 +324,16 @@ export interface DesktopKeystoreBridge { } export interface DesktopCertificatesBridge { - /** Forgets the client certificates chosen for servers, for the next account. Only narrows. */ - clearChoices: () => Promise; + /** + * Forgets client certificates chosen for servers. `account`: those chosen while that account was + * active, at its own sign-out. `last`: all of them, and what the app keeps of them in memory, at + * the sign-out that leaves nobody. Only narrows. + */ + clearChoices: (scope: { account?: string; last?: boolean }) => Promise; + /** The account a choice made from now on belongs to. */ + setAccount: (account: string | null) => Promise; + /** The app's connections were closed: a request under way will never settle by itself. */ + onConnectionsReset: (listener: () => void) => () => void; } export interface DesktopPolicyBridge { diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 77bd2eaec..2ac9dd25f 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1,5 +1,6 @@ import { recordJmapCall, reportJmapFailure } from "@/lib/error-reporting/client"; import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { onConnectionsReset } from '@/lib/desktop/connections-reset'; import { desktopJmapBase, desktopJmapOrigin, isDesktopProxiedServer, withDesktopBlobType } from '@/lib/desktop/jmap-route'; import { advertisesMailboxMetadata, bulwarkEntries, escapeMetadataPathSegment, MAX_METADATA_ENTRY_BYTES, METADATA_CAPABILITY, MetadataPatchError, MetadataRefusedError, MetadataTooLargeError, MetadataUnsupportedError, metadataValueBytes, namespaceFor } from "./bulwark-metadata"; import { IncompleteDeleteError } from "./delete-error"; @@ -1855,14 +1856,23 @@ export class JMAPClient implements IJMAPClient { // "we gave up" from "the caller cancelled", and callers must be able to tell // those apart (a cancelled send is not a failed send). let timedOut = false; - const timer = setTimeout(() => { + const giveUp = () => { timedOut = true; - controller.abort(); - }, timeoutMs); + // The reason goes on to a body still being read after this returned. + controller.abort(new RequestTimeoutError(timeoutMs)); + }; + const timer = setTimeout(giveUp, timeoutMs); + // The desktop app closed its connections, so this will never settle by itself. It's what a + // deadline running out means: the request may have reached the server, only the answer is + // lost, and it isn't replayed (authenticatedFetch). + const stopWatching = onConnectionsReset(giveUp); try { - return await fetch(url, { ...withJmapCredentials(init), headers, signal: controller.signal }); + const response = await fetch(url, { ...withJmapCredentials(init), headers, signal: controller.signal }); + if (stopWatching) setTimeout(stopWatching, timeoutMs); + return response; } catch (error) { + stopWatching?.(); if (timedOut) throw new RequestTimeoutError(timeoutMs); throw error; } finally { @@ -6363,6 +6373,7 @@ export class JMAPClient implements IJMAPClient { const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', onAbort); + stopWatchingConnections?.(); xhr.onload = xhr.onerror = xhr.onabort = xhr.ontimeout = null; xhr.upload.onprogress = null; }; @@ -6379,6 +6390,7 @@ export class JMAPClient implements IJMAPClient { }; const onAbort = () => abortWith(new DOMException('Upload aborted', 'AbortError')); const onTimeout = () => abortWith(new RequestTimeoutError(JMAPClient.TRANSFER_TIMEOUT_MS)); + const stopWatchingConnections = onConnectionsReset(onTimeout); const resetWatchdog = () => { clearTimeout(timer); // XHR uses this budget for inactivity, unlike fetch's deadline until @@ -11670,6 +11682,8 @@ export class JMAPClient implements IJMAPClient { * Checking on the way back in makes the recovery immediate rather than * leaving the app on a socket that will never deliver another byte. */ + private stopWatchingConnections: (() => void) | null = null; + private recycleStaleSSE(): void { if (this.intentionallyDisconnected) return; if (!this.sseAbortController) return; @@ -11704,6 +11718,14 @@ export class JMAPClient implements IJMAPClient { } }; window.addEventListener('online', this.onlineHandler); + // A stream under way when the desktop app closes its connections would stay silent. + this.stopWatchingConnections = onConnectionsReset(() => { + if (this.intentionallyDisconnected || !this.sseAbortController) return; + this.stopSSEPingMonitor(); + this.sseAbortController.abort(); + this.sseAbortController = null; + this.scheduleSSEReconnect(); + }); } } @@ -11716,6 +11738,8 @@ export class JMAPClient implements IJMAPClient { window.removeEventListener('online', this.onlineHandler); this.onlineHandler = null; } + this.stopWatchingConnections?.(); + this.stopWatchingConnections = null; } onConnectionChange(callback: (connected: boolean) => void): void { diff --git a/stores/__tests__/auth-store-logout-hooks.test.ts b/stores/__tests__/auth-store-logout-hooks.test.ts index 3e5e53f8d..afdad8ad2 100644 --- a/stores/__tests__/auth-store-logout-hooks.test.ts +++ b/stores/__tests__/auth-store-logout-hooks.test.ts @@ -79,27 +79,43 @@ describe('sign-out with plugins', () => { (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, certificates: { clearChoices } }; }; - it('are forgotten at sign-out', async () => { + it('all of them at the sign-out that leaves nobody', async () => { const clearChoices = vi.fn().mockResolvedValue(undefined); withBridge(clearChoices); await useAuthStore.getState().logout(); expect(clearChoices).toHaveBeenCalledTimes(1); + expect(clearChoices.mock.calls[0][0]).toMatchObject({ last: true }); }); - it('when all accounts sign out', async () => { + it('all of them when all accounts sign out', async () => { const clearChoices = vi.fn().mockResolvedValue(undefined); withBridge(clearChoices); await useAuthStore.getState().logoutAll(); - expect(clearChoices).toHaveBeenCalled(); + expect(clearChoices).toHaveBeenCalledWith({ last: true }); }); - it('and when switching to another account', () => { + it('only its own while another account stays', async () => { const clearChoices = vi.fn().mockResolvedValue(undefined); withBridge(clearChoices); + const accounts = useAccountStore.getState(); + const entry = (id: string) => ({ id, label: id, serverUrl: 'https://mail.example.test', username: id, authMode: 'basic' as const, rememberMe: true, displayName: id, email: id, lastLoginAt: 1, isConnected: true, hasError: false, isDefault: false, cookieSlot: id === 'a1' ? 0 : 1, avatarColor: '#000' }); + useAccountStore.setState({ ...accounts, accounts: [entry('a1'), entry('a2')], activeAccountId: 'a1' }); + useAuthStore.setState({ isAuthenticated: true, activeAccountId: 'a1' }); + await useAuthStore.getState().logout(); + expect(clearChoices).toHaveBeenCalledWith({ account: 'a1', last: false }); + expect(clearChoices.mock.calls.some(([scope]) => scope.last === true)).toBe(false); + }); + + it('nothing when switching to another account or searching across them', () => { + const clearChoices = vi.fn().mockResolvedValue(undefined); + const setAccount = vi.fn().mockResolvedValue(undefined); + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, certificates: { clearChoices, setAccount } }; useAuthStore.setState({ isAuthenticated: true, activeAccountId: 'a1' }); - expect(clearChoices).not.toHaveBeenCalled(); useAuthStore.setState({ activeAccountId: 'a2' }); - expect(clearChoices).toHaveBeenCalledTimes(1); + useAuthStore.setState({ activeAccountId: 'a1' }); + expect(clearChoices).not.toHaveBeenCalled(); + expect(setAccount.mock.calls.at(-1)).toEqual(['a1']); + expect(setAccount).toHaveBeenCalledWith('a2'); }); it('signs out all the same on a shell without the call, or one that fails', async () => { diff --git a/stores/__tests__/auth-store-token-renewal-timeout.test.ts b/stores/__tests__/auth-store-token-renewal-timeout.test.ts new file mode 100644 index 000000000..14a24176a --- /dev/null +++ b/stores/__tests__/auth-store-token-renewal-timeout.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * A token renewal holds the token lock for every window. A request that never answers, as one + * caught when the desktop app closes its connections, can't be left to hold it for good. + */ + +const SERVER = 'https://mail.example.test'; + +function account(id: string, slot: number) { + return { + id, + label: id, + serverUrl: SERVER, + username: `${id}@example.test`, + authMode: 'oauth' as const, + rememberMe: true, + displayName: id, + email: `${id}@example.test`, + lastLoginAt: 1, + isConnected: false, + hasError: false, + isDefault: true, + cookieSlot: slot, + avatarColor: '#000', + }; +} + +describe('a token renewal that gets no answer', { timeout: 30_000 }, () => { + const held = new Set(); + let renewals = 0; + + beforeEach(() => { + vi.resetModules(); + held.clear(); + renewals = 0; + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: { + request: async (name: string, options: { ifAvailable?: boolean }, callback: (lock: unknown) => unknown) => { + if (options.ifAvailable && held.has(name)) return callback(null); + held.add(name); + try { + return await callback({}); + } finally { + held.delete(name); + } + }, + }, + }); + vi.stubGlobal('fetch', vi.fn((url: unknown, init?: RequestInit) => { + if (String(url).includes('/api/auth/token') && init?.method === 'PUT') { + renewals += 1; + return new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))); + }); + } + return Promise.resolve(new Response('{}', { status: 200 })); + })); + }); + + afterEach(() => { + delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; + delete (navigator as { locks?: unknown }).locks; + vi.clearAllTimers(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + async function storeWithAccount() { + const { useAuthStore } = await import('../auth-store'); + const { useAccountStore } = await import('../account-store'); + useAccountStore.setState({ accounts: [account('A', 0)], activeAccountId: 'A', defaultAccountId: 'A' }); + useAuthStore.setState({ isAuthenticated: true, isLoading: false, serverUrl: SERVER, username: 'A@example.test', activeAccountId: 'A', authMode: 'oauth' }); + // After the imports: they don't finish on a stopped clock. + vi.useFakeTimers(); + return useAuthStore; + } + + it('lets go of the lock after its time, and counts as an outage', async () => { + const store = await storeWithAccount(); + const done = vi.fn(); + void store.getState().refreshAccessToken({ accountId: 'A' }).then(done); + await vi.advanceTimersByTimeAsync(0); + expect(renewals).toBe(1); + expect(held.size, 'it holds the lock while it runs').toBe(1); + await vi.advanceTimersByTimeAsync(29_000); + expect(done).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1_000); + expect(held.size).toBe(0); + expect(done).toHaveBeenCalledWith(null); + expect(store.getState().isAuthenticated, 'nobody is signed out for it').toBe(true); + }); + + it('lets go at once when the desktop app says its connections went', async () => { + let reset = () => {}; + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { + version: 1, + certificates: { + setAccount: async () => {}, + onConnectionsReset: (listener: () => void) => { + reset = listener; + return () => {}; + }, + }, + }; + const store = await storeWithAccount(); + const done = vi.fn(); + void store.getState().refreshAccessToken({ accountId: 'A' }).then(done); + await vi.advanceTimersByTimeAsync(0); + expect(held.size).toBe(1); + reset(); + await vi.advanceTimersByTimeAsync(0); + expect(held.size).toBe(0); + expect(done).toHaveBeenCalledWith(null); + }); +}); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 0fd01dfd7..7953d72a2 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -18,7 +18,8 @@ import { generateAccountId, MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; import { replaceWindowLocation, getPathPrefix, getLocaleFromPath, apiFetch } from '@/lib/browser-navigation'; import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; import { getDesktopBridge } from '@/lib/desktop/bridge'; -import { clearDesktopCertificateChoices, clearDesktopCrashFiles, clearKeystoreGrants } from '@/lib/desktop/keystore-grants'; +import { clearDesktopCertificateChoices, clearDesktopCrashFiles, clearKeystoreGrants, noteDesktopCertificateAccount } from '@/lib/desktop/keystore-grants'; +import { onConnectionsReset } from '@/lib/desktop/connections-reset'; import { DESKTOP_CLIENT_ID_KEY } from '@/lib/desktop/browser-sign-in'; import { isEmbedded, notifyParent } from '@/lib/iframe-bridge'; import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll, forgetAccountRows } from '@/lib/account-state-manager'; @@ -359,6 +360,26 @@ function trackSessionWrite(slot: number, run: (signal: AbortSignal) => Promis return request; } +/** It holds the token lock, for every window: a request that never answers can't be left to hold it. */ +const TOKEN_RENEWAL_TIMEOUT_MS = 30_000; + +function renewalSignal(outer: AbortSignal): { signal: AbortSignal; done: () => void } { + const controller = new AbortController(); + const stop = () => controller.abort(); + if (outer.aborted) stop(); + else outer.addEventListener('abort', stop, { once: true }); + const timer = setTimeout(stop, TOKEN_RENEWAL_TIMEOUT_MS); + const stopWatching = onConnectionsReset(stop); + return { + signal: controller.signal, + done: () => { + clearTimeout(timer); + outer.removeEventListener('abort', stop); + stopWatching?.(); + }, + }; +} + const TIMED_OUT = Symbol('timed out'); function withTimeout(promise: Promise, ms: number): Promise { @@ -649,8 +670,10 @@ function fetchSlotAccessToken(slot: number, opts: { force?: boolean; remember?: const force = opts.force && !contended; if (!IS_LITE) { const query = `${force ? '&force=true' : ''}${opts.remember === false ? '&remember=false' : ''}`; - return apiFetch(`/api/auth/token?slot=${slot}${query}`, { method: 'PUT', signal }) - .then((res) => noteSessionAnswer(res, slot)); + const renewal = renewalSignal(signal); + return apiFetch(`/api/auth/token?slot=${slot}${query}`, { method: 'PUT', signal: renewal.signal }) + .then((res) => noteSessionAnswer(res, slot)) + .finally(renewal.done); } // Like the route's cookie cache: a restore resumes with the token it had, // since a renewal before the refresh token's `nbf` is refused (#552). @@ -2544,7 +2567,7 @@ export const useAuthStore = create()( useSettingsStore.getState().disableSync(); clearKeystoreGrants(); - clearDesktopCertificateChoices(); + clearDesktopCertificateChoices({ account: accountId ?? undefined, last: useAccountStore.getState().accounts.length === 0 }); clearDesktopCrashFiles(); void authHooks.onAfterLogout.emit({ accountId: accountId ?? 'all', @@ -2636,6 +2659,7 @@ export const useAuthStore = create()( // mail never passed a question, so it waits for their next sign-in. accountStore.removeAccount(remaining.id, 'evicted'); } + clearDesktopCertificateChoices({ last: true }); } // Full logout. The credentials are cleared before the page state: @@ -2680,6 +2704,7 @@ export const useAuthStore = create()( evictAccount(accountId); forgetAccountRows(accountId, get().client); accountStore.removeAccount(accountId, reason); + clearDesktopCertificateChoices({ account: accountId }); forgetPrincipals(accountId); clearPluginStorageForAccount(accountId); }, @@ -2726,7 +2751,7 @@ export const useAuthStore = create()( } clearKeystoreGrants(); - clearDesktopCertificateChoices(); + clearDesktopCertificateChoices({ last: true }); clearDesktopCrashFiles(); void authHooks.onAfterLogout.emit({ accountId: 'all' @@ -3874,9 +3899,9 @@ useAuthStore.subscribe((state, prev) => { const ready = state.isAuthenticated && !!state.client && !!state.activeAccountId; const wasReady = prev.isAuthenticated && !!prev.client && !!prev.activeAccountId; if (ready && !wasReady) void authHooks.onAuthReady.emit({ accountId: state.activeAccountId }); + if (state.activeAccountId !== prev.activeAccountId) noteDesktopCertificateAccount(state.activeAccountId); if (state.activeAccountId && prev.activeAccountId && state.activeAccountId !== prev.activeAccountId) { clearKeystoreGrants(); - clearDesktopCertificateChoices(); clearDesktopCrashFiles(); void authHooks.onAccountSwitch.emit({ accountId: state.activeAccountId, previousAccountId: prev.activeAccountId }); }