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/__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/__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/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 2fc4cf7df..a16f2baf3 100644
--- a/lib/desktop/keystore-grants.ts
+++ b/lib/desktop/keystore-grants.ts
@@ -10,6 +10,21 @@ export function clearKeystoreGrants(): void {
void Promise.resolve(getDesktopBridge()?.keystore?.clearGrants?.()).catch(() => {});
}
+/**
+ * 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(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(() => {});
+}
+
/**
* 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..65a51d161 100644
--- a/lib/desktop/types.ts
+++ b/lib/desktop/types.ts
@@ -323,6 +323,19 @@ export interface DesktopKeystoreBridge {
clearGrants?: () => Promise;
}
+export interface DesktopCertificatesBridge {
+ /**
+ * 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 {
/** What's in force now. Synchronous: a page can read it in its first render. */
get: () => PolicySnapshot;
@@ -623,6 +636,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/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/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..afdad8ad2 100644
--- a/stores/__tests__/auth-store-logout-hooks.test.ts
+++ b/stores/__tests__/auth-store-logout-hooks.test.ts
@@ -73,6 +73,62 @@ 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('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('all of them when all accounts sign out', async () => {
+ const clearChoices = vi.fn().mockResolvedValue(undefined);
+ withBridge(clearChoices);
+ await useAuthStore.getState().logoutAll();
+ expect(clearChoices).toHaveBeenCalledWith({ last: true });
+ });
+
+ 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' });
+ useAuthStore.setState({ activeAccountId: 'a2' });
+ 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 () => {
+ 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/__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 2250c9ec9..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 { 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,6 +2567,7 @@ export const useAuthStore = create()(
useSettingsStore.getState().disableSync();
clearKeystoreGrants();
+ clearDesktopCertificateChoices({ account: accountId ?? undefined, last: useAccountStore.getState().accounts.length === 0 });
clearDesktopCrashFiles();
void authHooks.onAfterLogout.emit({
accountId: accountId ?? 'all',
@@ -2635,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:
@@ -2679,6 +2704,7 @@ export const useAuthStore = create()(
evictAccount(accountId);
forgetAccountRows(accountId, get().client);
accountStore.removeAccount(accountId, reason);
+ clearDesktopCertificateChoices({ account: accountId });
forgetPrincipals(accountId);
clearPluginStorageForAccount(accountId);
},
@@ -2725,6 +2751,7 @@ export const useAuthStore = create()(
}
clearKeystoreGrants();
+ clearDesktopCertificateChoices({ last: true });
clearDesktopCrashFiles();
void authHooks.onAfterLogout.emit({
accountId: 'all'
@@ -3872,6 +3899,7 @@ 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();
clearDesktopCrashFiles();