From 2db3c4b2175c5eb31303ca70b7b941d88b42c55d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:58:48 +0100 Subject: [PATCH 1/2] feat: let privileged plugins sign and decrypt with the OS certificate store in the desktop app --- lib/__tests__/plugin-keystore-runtime.test.ts | 106 +++++++ lib/__tests__/plugin-keystore.test.ts | 291 ++++++++++++++++++ lib/desktop/types.ts | 62 ++++ lib/extension-package.ts | 7 + lib/plugin-sandbox/host-api-keystore.ts | 130 ++++++++ lib/plugin-sandbox/host-api.ts | 6 + lib/plugin-sandbox/host-bridge.ts | 3 +- lib/plugin-sandbox/host-dialog.ts | 25 +- lib/plugin-sandbox/host-features.ts | 2 + lib/plugin-sandbox/permission-text.ts | 2 + lib/plugin-sandbox/runtime.tsx | 17 + lib/plugin-types.ts | 10 + locales/ar/common.json | 4 + locales/ca/common.json | 4 + locales/cs/common.json | 4 + locales/da/common.json | 4 + locales/de/common.json | 4 + locales/en/common.json | 4 + locales/es/common.json | 4 + locales/et/common.json | 4 + locales/fa/common.json | 4 + locales/fr/common.json | 4 + locales/he/common.json | 4 + locales/hu/common.json | 4 + locales/it/common.json | 4 + locales/ja/common.json | 4 + locales/ko/common.json | 4 + locales/lv/common.json | 4 + locales/mn/common.json | 4 + locales/nb/common.json | 4 + locales/nl/common.json | 4 + locales/pl/common.json | 4 + locales/pt/common.json | 4 + locales/ro/common.json | 4 + locales/ru/common.json | 4 + locales/sk/common.json | 4 + locales/th/common.json | 4 + locales/tr/common.json | 4 + locales/uk/common.json | 4 + locales/vi/common.json | 4 + locales/zh-TW/common.json | 4 + locales/zh/common.json | 4 + 42 files changed, 778 insertions(+), 3 deletions(-) create mode 100644 lib/__tests__/plugin-keystore-runtime.test.ts create mode 100644 lib/__tests__/plugin-keystore.test.ts create mode 100644 lib/plugin-sandbox/host-api-keystore.ts diff --git a/lib/__tests__/plugin-keystore-runtime.test.ts b/lib/__tests__/plugin-keystore-runtime.test.ts new file mode 100644 index 000000000..d50e1b172 --- /dev/null +++ b/lib/__tests__/plugin-keystore-runtime.test.ts @@ -0,0 +1,106 @@ +/** + * api.keystore in the sandbox runtime. A PIN prompt can take minutes, so the facade + * must not give up after the RPC's usual 30 seconds, and errors have to reach the + * plugin as the bare code. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import { cleanup, render, waitFor } from '@testing-library/react'; + +vi.mock('@/stores/toast-store', () => ({ + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }, +})); + +const HOST_ORIGIN = window.location.origin; + +function post(source: unknown, data: unknown): void { + const ev = new MessageEvent('message', { data, origin: HOST_ORIGIN }); + Object.defineProperty(ev, 'source', { value: source }); + window.dispatchEvent(ev); +} + +interface KeystoreFacade { + list(): Promise; + sign(id: string, digest: Uint8Array, scheme: unknown): Promise; + decrypt(id: string, data: Uint8Array, scheme: unknown): Promise; + agree(id: string, peer: Uint8Array): Promise; +} + +describe('api.keystore in the sandbox', () => { + let hostWindow: { postMessage: ReturnType }; + const realParent = Object.getOwnPropertyDescriptor(window, 'parent'); + const g = globalThis as { __pluginApi?: { keystore: KeystoreFacade; host: { features: Record } } }; + + beforeEach(() => { + vi.resetModules(); + hostWindow = { postMessage: vi.fn() }; + Object.defineProperty(window, 'parent', { configurable: true, get: () => hostWindow as unknown as Window }); + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); + }); + afterEach(() => { + vi.useRealTimers(); + cleanup(); + vi.unstubAllGlobals(); + delete g.__pluginApi; + if (realParent) Object.defineProperty(window, 'parent', realParent); + }); + + const requests = () => + hostWindow.postMessage.mock.calls.map((call) => call[0] as { type: string; id: string; method: string; args: unknown[] }).filter((message) => message.type === 'api-request'); + + async function boot(features: Record): Promise { + const bundle = 'module.exports = { activate: function (api) { globalThis.__pluginApi = api; } };'; + const { SandboxRuntime } = await import('@/lib/plugin-sandbox/runtime'); + render(React.createElement(SandboxRuntime)); + post(hostWindow, { + type: 'init', + payload: { + mode: 'background', pluginId: 'smime', tier: 'privileged', locale: 'en', + manifest: { id: 'smime', version: '1.0.0', permissions: ['crypto:full', 'keystore'], settings: {} }, + code: bundle, + hostFeatures: { externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: false, ...features }, + }, + }); + await waitFor(() => expect(g.__pluginApi).toBeDefined()); + } + + it('the flag reaches the plugin, and is not there when the host does not send it', async () => { + await boot({ keystore: true }); + expect(g.__pluginApi!.host.features.keystore).toBe(true); + post(hostWindow, { type: 'host-change', features: { externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: false } }); + expect(g.__pluginApi!.host.features.keystore).toBeFalsy(); + }, 30_000); + + it('no call gives up while the host has not answered: a PIN can take minutes', async () => { + await boot({ keystore: true }); + vi.useFakeTimers(); + const api = g.__pluginApi!.keystore; + const digest = new Uint8Array(32); + const settled: string[] = []; + const watch = (name: string, promise: Promise) => promise.then(() => settled.push(name), () => settled.push(`${name} rejected`)); + watch('list', api.list()); + watch('sign', api.sign('id', digest, { scheme: 'rsa-pkcs1', hash: 'SHA-256' })); + watch('decrypt', api.decrypt('id', new Uint8Array(256), { scheme: 'rsa-pkcs1' })); + watch('agree', api.agree('id', new Uint8Array(91))); + // Past the RPC's 30 s and past the network calls' 120 s. + await vi.advanceTimersByTimeAsync(600_000); + expect(settled).toEqual([]); + expect(requests().map((request) => request.method)).toEqual(['keystore.list', 'keystore.sign', 'keystore.decrypt', 'keystore.agree']); + }, 30_000); + + it('passes the bytes as bytes, and hands an error back with the code as its message', async () => { + await boot({ keystore: true }); + const api = g.__pluginApi!.keystore; + const digest = new Uint8Array(32).fill(9); + const scheme = { scheme: 'rsa-pss', hash: 'SHA-256' }; + const signing = api.sign('the-id', digest, scheme); + const sent = requests().at(-1)!; + expect(sent.args).toEqual(['the-id', digest, scheme]); + post(hostWindow, { type: 'api-response', id: sent.id, ok: false, error: 'keystore:cancelled' }); + await expect(signing).rejects.toThrow(/^keystore:cancelled$/); + + const decrypting = api.decrypt('the-id', new Uint8Array(256), { scheme: 'rsa-pkcs1' }); + post(hostWindow, { type: 'api-response', id: requests().at(-1)!.id, ok: true, result: new Uint8Array([1, 2]) }); + await expect(decrypting).resolves.toEqual(new Uint8Array([1, 2])); + }, 30_000); +}); diff --git a/lib/__tests__/plugin-keystore.test.ts b/lib/__tests__/plugin-keystore.test.ts new file mode 100644 index 000000000..e8109181b --- /dev/null +++ b/lib/__tests__/plugin-keystore.test.ts @@ -0,0 +1,291 @@ +/** + * api.keystore: the desktop app's OS certificate store, for a privileged plugin. The + * refusals are what matters here: an untrusted plugin never reaches the bridge, the + * flag only says yes when the call would work, and a call waiting on the OS's PIN + * prompt is not a hung hook. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { InstalledPlugin } from '../plugin-types'; +import { clearAllHooks, pluginErrorTracker, renderHooks, setHookDialogGate } from '../plugin-hooks'; +import { checkPluginManifest } from '../extension-package'; +import { dispatchApiCall, PERM_PER_METHOD } from '../plugin-sandbox/host-api'; +import { hasDialogFor, holdForUser, whenNoDialogFor } from '../plugin-sandbox/host-dialog'; +import { snapshotHostFeatures } from '../plugin-sandbox/host-features'; +import { keystoreFeature } from '../plugin-sandbox/host-api-keystore'; +import { HIGH_RISK_PERMISSIONS } from '../plugin-sandbox/permission-text'; + +vi.mock('@/stores/toast-store', () => ({ + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }, +})); + +function plugin(overrides: Partial = {}): InstalledPlugin { + return { + id: 'smime', + name: 'S/MIME', + version: '1.0.0', + author: 'test', + description: '', + type: 'hook', + tier: 'privileged', + managed: true, + permissions: ['crypto:full', 'keystore'], + entrypoint: 'index.js', + enabled: true, + status: 'running', + settings: {}, + ...overrides, + }; +} + +const DIGEST = new Uint8Array(32).fill(7); +const SIGN = { scheme: 'rsa-pkcs1', hash: 'SHA-256' }; + +function fakeBridge() { + return { + list: vi.fn(async () => []), + sign: vi.fn(async () => new Uint8Array([1, 2, 3])), + decrypt: vi.fn(async () => new Uint8Array([4, 5, 6])), + agree: vi.fn(async () => new Uint8Array([7, 8, 9])), + }; +} + +function installBridge(bridge: ReturnType | undefined): void { + (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = bridge ? { version: 1, platform: 'win32', keystore: bridge } : undefined; +} + +afterEach(() => { + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; +}); + +describe('who may call it', () => { + let bridge: ReturnType; + beforeEach(() => { + bridge = fakeBridge(); + installBridge(bridge); + }); + + const METHODS: Array<[string, unknown[]]> = [ + ['keystore.list', []], + ['keystore.sign', ['id', DIGEST, SIGN]], + ['keystore.decrypt', ['id', new Uint8Array(256), { scheme: 'rsa-pkcs1' }]], + ['keystore.agree', ['id', new Uint8Array(91)]], + ]; + + it.each(METHODS)('%s is refused for the untrusted tier, even when declared and granted', async (method, args) => { + const untrusted = plugin({ tier: 'untrusted', permissions: ['keystore'], grantedPermissions: ['keystore'], managed: true }); + await expect(dispatchApiCall(untrusted, method, args, { privileged: false })).rejects.toThrow(/requires the privileged plugin tier/); + expect(bridge.list).not.toHaveBeenCalled(); + expect(bridge.sign).not.toHaveBeenCalled(); + expect(bridge.decrypt).not.toHaveBeenCalled(); + expect(bridge.agree).not.toHaveBeenCalled(); + }); + + it.each(METHODS)('%s is refused without the permission, also in the privileged tier', async (method, args) => { + const noPermission = plugin({ permissions: ['crypto:full'] }); + await expect(dispatchApiCall(noPermission, method, args, { privileged: true })).rejects.toThrow(/lacks permission "keystore"/); + expect(bridge.sign).not.toHaveBeenCalled(); + }); + + it('is refused when a manifest declares it but neither an admin nor the user gave it', async () => { + const notGiven = plugin({ managed: false, grantedPermissions: [] }); + await expect(dispatchApiCall(notGiven, 'keystore.list', [], { privileged: true })).rejects.toThrow(/lacks permission "keystore"/); + }); + + it('passes for a privileged plugin holding it', async () => { + await expect(dispatchApiCall(plugin(), 'keystore.sign', ['id', DIGEST, SIGN], { privileged: true })).resolves.toEqual(new Uint8Array([1, 2, 3])); + expect(bridge.sign).toHaveBeenCalledWith('id', DIGEST, SIGN); + await expect(dispatchApiCall(plugin(), 'keystore.list', [], { privileged: true })).resolves.toEqual([]); + await expect(dispatchApiCall(plugin(), 'keystore.decrypt', ['id', new Uint8Array(256), { scheme: 'rsa-pkcs1' }], { privileged: true })).resolves.toEqual(new Uint8Array([4, 5, 6])); + await expect(dispatchApiCall(plugin(), 'keystore.agree', ['id', new Uint8Array(91)], { privileged: true })).resolves.toEqual(new Uint8Array([7, 8, 9])); + }); + + it('says it is unsupported in a browser or a shell without the helper', async () => { + installBridge(undefined); + await expect(dispatchApiCall(plugin(), 'keystore.list', [], { privileged: true })).rejects.toThrow('keystore:unsupported'); + }); + + it('every keystore method is in the permission table', () => { + for (const [method] of METHODS) expect(PERM_PER_METHOD[method]).toBe('keystore'); + }); + + it('is shown as high risk in the consent dialog', () => { + expect(HIGH_RISK_PERMISSIONS.has('keystore')).toBe(true); + }); +}); + +describe('what gets through to the shell', () => { + let bridge: ReturnType; + beforeEach(() => { + bridge = fakeBridge(); + installBridge(bridge); + }); + const call = (method: string, ...args: unknown[]) => dispatchApiCall(plugin(), method, args, { privileged: true }); + + it.each<[string, unknown[]]>([ + ['an id that is no string', [42, DIGEST, SIGN]], + ['an empty id', ['', DIGEST, SIGN]], + ['a long id', ['x'.repeat(65), DIGEST, SIGN]], + ['a digest that is no bytes', ['id', 'not bytes', SIGN]], + ['an empty digest', ['id', new Uint8Array(0), SIGN]], + ['a digest longer than any hash', ['id', new Uint8Array(65), SIGN]], + ['no scheme', ['id', DIGEST, null]], + ['a list as scheme', ['id', DIGEST, []]], + ['an unknown scheme', ['id', DIGEST, { scheme: 'raw', hash: 'SHA-256' }]], + ['an unknown hash', ['id', DIGEST, { scheme: 'rsa-pkcs1', hash: 'MD5' }]], + ['no hash', ['id', DIGEST, { scheme: 'rsa-pkcs1' }]], + ])('sign refuses %s', async (_name, args) => { + await expect(call('keystore.sign', ...args)).rejects.toThrow('keystore:invalid'); + expect(bridge.sign).not.toHaveBeenCalled(); + }); + + it('decrypt refuses data past any modulus, and a label too long', async () => { + await expect(call('keystore.decrypt', 'id', new Uint8Array(2049), { scheme: 'rsa-pkcs1' })).rejects.toThrow('keystore:invalid'); + await expect(call('keystore.decrypt', 'id', new Uint8Array(256), { scheme: 'rsa-oaep', hash: 'SHA-1', label: new Uint8Array(257) })).rejects.toThrow('keystore:invalid'); + await expect(call('keystore.decrypt', 'id', new Uint8Array(256), { scheme: 'rsa-oaep' })).rejects.toThrow('keystore:invalid'); + expect(bridge.decrypt).not.toHaveBeenCalled(); + }); + + it('passes only the fields it knows', async () => { + const data = new Uint8Array(256); + await call('keystore.decrypt', 'id', data, { scheme: 'rsa-oaep', hash: 'SHA-1', extra: 'x', label: new Uint8Array(0) }); + expect(bridge.decrypt).toHaveBeenCalledWith('id', data, { scheme: 'rsa-oaep', hash: 'SHA-1' }); + await call('keystore.sign', 'id', DIGEST, { ...SIGN, extra: 'x' }); + expect(bridge.sign).toHaveBeenCalledWith('id', DIGEST, SIGN); + }); + + it('agree refuses an oversized peer key', async () => { + await expect(call('keystore.agree', 'id', new Uint8Array(257))).rejects.toThrow('keystore:invalid'); + expect(bridge.agree).not.toHaveBeenCalled(); + }); + + it.each(['cancelled', 'not-found', 'unsupported', 'locked', 'failed'])('hands keystore:%s to the frame as the message', async (code) => { + bridge.sign.mockRejectedValueOnce(new Error(`Error invoking remote method: Error: keystore:${code}`)); + await expect(call('keystore.sign', 'id', DIGEST, SIGN)).rejects.toThrow(new RegExp(`^keystore:${code}$`)); + }); + + it('turns anything else into keystore:failed, not a stack or a path', async () => { + bridge.sign.mockRejectedValueOnce(new Error('C:\\secret\\path blew up')); + await expect(call('keystore.sign', 'id', DIGEST, SIGN)).rejects.toThrow(/^keystore:failed$/); + }); +}); + +describe('api.host.features.keystore', () => { + it('is true for a privileged plugin with the permission, in a shell that has it', () => { + installBridge(fakeBridge()); + expect(keystoreFeature(plugin())).toEqual({ keystore: true }); + expect(snapshotHostFeatures(plugin()).keystore).toBe(true); + }); + + it('is absent in a browser', () => { + expect(keystoreFeature(plugin())).toEqual({}); + expect(snapshotHostFeatures(plugin())).not.toHaveProperty('keystore'); + }); + + it('is absent for the untrusted tier, without the permission, and for a privileged claim that fails its gates', () => { + installBridge(fakeBridge()); + expect(keystoreFeature(plugin({ tier: 'untrusted' }))).toEqual({}); + expect(keystoreFeature(plugin({ permissions: ['crypto:full'] }))).toEqual({}); + // The tier resolver refuses these two: no admin install, no crypto:full. + expect(keystoreFeature(plugin({ managed: false, grantedPermissions: ['keystore'] }))).toEqual({}); + expect(keystoreFeature(plugin({ permissions: ['keystore'] }))).toEqual({}); + }); + + it('is absent when no plugin is named, so one plugin\'s flag is never everyone\'s', () => { + installBridge(fakeBridge()); + expect(snapshotHostFeatures()).not.toHaveProperty('keystore'); + }); +}); + +describe('the manifest', () => { + const BASE = { id: 'keystore-test', name: 'P', version: '1.0.0', author: 'a', description: 'd', type: 'hook' }; + + it('grants it to a privileged plugin as an optional permission', () => { + const { manifest, errors } = checkPluginManifest({ ...BASE, tier: 'privileged', permissions: ['crypto:full'], optionalPermissions: ['keystore'] }); + expect(errors).toEqual([]); + expect(manifest!.permissions).toContain('keystore'); + }); + + it('leaves it out for an untrusted plugin, with a warning, as optional and as plain', () => { + for (const declared of [{ optionalPermissions: ['keystore', 'email:read'] }, { permissions: ['keystore', 'email:read'] }]) { + const { manifest, errors, warnings } = checkPluginManifest({ ...BASE, ...declared }); + expect(errors).toEqual([]); + expect(manifest!.permissions).toEqual(['email:read']); + expect(warnings.join()).toMatch(/"keystore" permission: it needs the privileged tier/); + } + }); +}); + +describe('a keystore call and the hook timer', () => { + const ID = 'smime'; + + beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + pluginErrorTracker.resetAll(); + setHookDialogGate({ hasOpenDialog: hasDialogFor, whenDialogsClosed: whenNoDialogFor }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + setHookDialogGate(null); + clearAllHooks(); + pluginErrorTracker.resetAll(); + }); + + it('a hold counts as an open dialog until released, however many there are', () => { + expect(hasDialogFor(ID)).toBe(false); + const first = holdForUser(ID); + const second = holdForUser(ID); + expect(hasDialogFor(ID)).toBe(true); + first(); + first(); // Releasing twice doesn't release the other's hold. + expect(hasDialogFor(ID)).toBe(true); + second(); + expect(hasDialogFor(ID)).toBe(false); + }); + + it('a PIN prompt longer than the hook budget neither times out nor counts as an error', async () => { + const bridge = fakeBridge(); + let answerPin: () => void = () => {}; + bridge.decrypt.mockImplementation(() => new Promise>((resolve) => { answerPin = () => resolve(new Uint8Array([1])); })); + installBridge(bridge); + + // What an S/MIME plugin does: unwrap the content key inside its render hook. + renderHooks.onRenderEmailBody.register(ID, async (body: object) => { + await dispatchApiCall(plugin(), 'keystore.decrypt', ['id', new Uint8Array(256), { scheme: 'rsa-pkcs1' }], { privileged: true }); + return { ...body, html: 'decrypted', handledBy: ID }; + }); + + const ran = renderHooks.onRenderEmailBody.transform({ html: '' }); + // Far past the 5 s budget: the user is looking for their card. + await vi.advanceTimersByTimeAsync(120_000); + expect(console.error).not.toHaveBeenCalled(); + expect(hasDialogFor(ID)).toBe(true); + + answerPin(); + await expect(ran).resolves.toEqual({ html: 'decrypted', handledBy: ID }); + expect(console.error).not.toHaveBeenCalled(); + expect(hasDialogFor(ID)).toBe(false); + expect(pluginErrorTracker.isDisabled(ID)).toBe(false); + }); + + it('the hold ends when the call fails too, so a later hang still times out', async () => { + const bridge = fakeBridge(); + bridge.sign.mockRejectedValueOnce(new Error('keystore:cancelled')); + installBridge(bridge); + await expect(dispatchApiCall(plugin(), 'keystore.sign', ['id', DIGEST, SIGN], { privileged: true })).rejects.toThrow('keystore:cancelled'); + expect(hasDialogFor(ID)).toBe(false); + + await expect(dispatchApiCall(plugin(), 'keystore.sign', [42], { privileged: true })).rejects.toThrow('keystore:invalid'); + expect(hasDialogFor(ID)).toBe(false); + }); + + it('a hook that hangs with no keystore call still times out', async () => { + renderHooks.onRenderEmailBody.register(ID, () => new Promise(() => {})); + const ran = renderHooks.onRenderEmailBody.transform({ html: 'as it was' }); + await vi.advanceTimersByTimeAsync(6000); + await expect(ran).resolves.toEqual({ html: 'as it was' }); + expect(console.error).toHaveBeenCalled(); + }); +}); diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index e049b727b..4f6502313 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -111,6 +111,67 @@ export interface DesktopPluginsBridge { install: (slug: string, version?: string) => Promise; } +export type DesktopKeystoreHash = 'SHA-1' | 'SHA-256' | 'SHA-384' | 'SHA-512'; + +export interface DesktopKeystoreCertificate { + /** Opaque: the only ids sign, decrypt and agree accept. */ + id: string; + /** The leaf, DER. */ + certificate: Uint8Array; + /** Intermediates, DER, without leaf and root. May be empty. */ + chain: Uint8Array[]; + subject: string; + issuer: string; + /** Hex. */ + serial: string; + /** Milliseconds since 1970. */ + notBefore: number; + notAfter: number; + /** keyUsage names. Empty without the extension. */ + usages: string[]; + /** extendedKeyUsage OIDs. Empty without the extension. */ + extendedUsages: string[]; + /** The key's algorithm OID. */ + algorithm: string; + keyType: 'rsa' | 'ec'; + curve?: 'P-256' | 'P-384' | 'P-521'; + keyBits: number; + /** A hint for the UI. */ + source: 'software' | 'smartcard' | 'keychain' | 'unknown'; + signSchemes: string[]; + decryptSchemes: string[]; + canAgree: boolean; +} + +export type DesktopKeystoreSignScheme = + | { scheme: 'rsa-pkcs1'; hash: DesktopKeystoreHash } + /** MGF1 on the same hash, salt as long as the hash. */ + | { scheme: 'rsa-pss'; hash: DesktopKeystoreHash } + /** `hash` names the digest's length. */ + | { scheme: 'ecdsa'; hash: DesktopKeystoreHash }; + +export type DesktopKeystoreDecryptScheme = + | { scheme: 'rsa-pkcs1' } + /** MGF1 on the same hash. */ + | { scheme: 'rsa-oaep'; hash: DesktopKeystoreHash; label?: Uint8Array }; + +/** + * The OS certificate store and smart cards. Present only where the native helper + * loaded and the OS has one. Failures reject with a bare code as the message: + * keystore:cancelled, not-found, unsupported, locked, invalid, failed. The key never + * leaves the OS, and its own prompts stay on, so a call may take as long as a PIN. + */ +export interface DesktopKeystoreBridge { + /** Certificates fit for mail that have a private key. Never prompts. */ + list: () => Promise; + /** RSA gives the raw signature, ECDSA a DER ECDSA-Sig-Value. */ + sign: (id: string, digest: Uint8Array, scheme: DesktopKeystoreSignScheme) => Promise; + /** Unwraps a CMS content key. */ + decrypt: (id: string, data: Uint8Array, scheme: DesktopKeystoreDecryptScheme) => Promise; + /** The raw shared secret, big-endian. The peer's key is an EC SubjectPublicKeyInfo. */ + agree: (id: string, peerPublicKey: Uint8Array) => Promise; +} + export interface BulwarkDesktopBridge { version: number; platform: 'win32' | 'darwin' | 'linux'; @@ -123,6 +184,7 @@ export interface BulwarkDesktopBridge { servers?: DesktopServersBridge; auth?: DesktopAuthBridge; plugins?: DesktopPluginsBridge; + keystore?: DesktopKeystoreBridge; } declare global { diff --git a/lib/extension-package.ts b/lib/extension-package.ts index 92942537f..baf286769 100644 --- a/lib/extension-package.ts +++ b/lib/extension-package.ts @@ -456,6 +456,13 @@ export function checkPluginManifest(m: Record, opts: { appVersi permissions = [...new Set([...permissions, ...asked.filter((p) => KNOWN_PERMISSIONS.has(p))])]; } } + // Only the privileged tier may reach the OS certificate store. Left out rather than + // refused, like an optional permission this version doesn't offer: the host would + // refuse the calls anyway, and consent shouldn't ask for what can't be used. + if (tier !== 'privileged' && permissions.includes('keystore')) { + warnings.push('Left out the "keystore" permission: it needs the privileged tier'); + permissions = permissions.filter((p) => p !== 'keystore'); + } if (tier === 'privileged' && !permissions.includes('crypto:full')) { errors.push('A privileged plugin must request the "crypto:full" permission'); } diff --git a/lib/plugin-sandbox/host-api-keystore.ts b/lib/plugin-sandbox/host-api-keystore.ts new file mode 100644 index 000000000..cb05d215b --- /dev/null +++ b/lib/plugin-sandbox/host-api-keystore.ts @@ -0,0 +1,130 @@ +// Host side of api.keystore: the OS certificate store and smart cards, for an +// S/MIME plugin, in the desktop app. Signing and decrypting happen in the OS. The +// private key never reaches the webmail, and the OS's own prompts stay on. +// +// Privileged tier only (host-api.ts), so a user-uploaded plugin never gets here. +// A privileged frame is same-origin and can reach window.parent.bulwarkDesktop +// directly; these checks are for the front door, and the safeguards that hold in +// both cases are in the shell and the OS. +// +// RPC errors reach the frame as message strings only, so the code is the message: +// keystore:cancelled, keystore:not-found, keystore:unsupported, keystore:locked, +// keystore:invalid, keystore:failed. + +import type { InstalledPlugin } from '../plugin-types'; +import type { DesktopKeystoreBridge, DesktopKeystoreCertificate } from '../desktop/types'; +import { holdForUser } from './host-dialog'; +import { pluginHasPermission } from './permissions'; +import { resolvePluginTier } from './tier'; + +const MAX_ID = 64; +const MAX_DIGEST = 64; +// A 16384-bit modulus, the most anything issues. +const MAX_CIPHERTEXT = 2048; +const MAX_LABEL = 256; +const MAX_PEER_KEY = 256; + +const HASHES = new Set(['SHA-1', 'SHA-256', 'SHA-384', 'SHA-512']); +const SIGN_SCHEMES = new Set(['rsa-pkcs1', 'rsa-pss', 'ecdsa']); +const DECRYPT_SCHEMES = new Set(['rsa-pkcs1', 'rsa-oaep']); + +export const KEYSTORE_PERMS = { + 'keystore.list': 'keystore', + 'keystore.sign': 'keystore', + 'keystore.decrypt': 'keystore', + 'keystore.agree': 'keystore', +} as const; + +export const KEYSTORE_METHODS: readonly string[] = Object.keys(KEYSTORE_PERMS); + +function desktopKeystore(): DesktopKeystoreBridge | undefined { + return typeof window === 'undefined' ? undefined : window.bulwarkDesktop?.keystore; +} + +/** api.host.features.keystore for this plugin. Absent rather than false when it's not: older hosts don't name it. */ +export function keystoreFeature(plugin?: InstalledPlugin): { keystore?: true } { + if (!plugin || !desktopKeystore()) return {}; + if (resolvePluginTier(plugin).tier !== 'privileged' || !pluginHasPermission(plugin, 'keystore')) return {}; + return { keystore: true }; +} + +function invalid(): Error { + return new Error('keystore:invalid'); +} + +function readId(value: unknown): string { + if (typeof value !== 'string' || !value || value.length > MAX_ID) throw invalid(); + return value; +} + +function readBytes(value: unknown, max: number): Uint8Array { + const bytes = value instanceof ArrayBuffer ? new Uint8Array(value) : value; + if (!(bytes instanceof Uint8Array) || bytes.byteLength === 0 || bytes.byteLength > max) throw invalid(); + return bytes; +} + +function readScheme(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw invalid(); + return value as Record; +} + +function readHash(value: unknown): string { + if (typeof value !== 'string' || !HASHES.has(value)) throw invalid(); + return value; +} + +function checkedSignScheme(value: unknown): { scheme: string; hash: string } { + const scheme = readScheme(value); + if (typeof scheme.scheme !== 'string' || !SIGN_SCHEMES.has(scheme.scheme)) throw invalid(); + return { scheme: scheme.scheme, hash: readHash(scheme.hash) }; +} + +function checkedDecryptScheme(value: unknown): { scheme: string; hash?: string; label?: Uint8Array } { + const scheme = readScheme(value); + if (typeof scheme.scheme !== 'string' || !DECRYPT_SCHEMES.has(scheme.scheme)) throw invalid(); + if (scheme.scheme === 'rsa-pkcs1') return { scheme: scheme.scheme }; + const label = scheme.label === undefined || (scheme.label instanceof Uint8Array && scheme.label.byteLength === 0) + ? undefined + : readBytes(scheme.label, MAX_LABEL); + return { scheme: scheme.scheme, hash: readHash(scheme.hash), ...(label ? { label } : {}) }; +} + +/** Only the code, whatever the bridge put around it. */ +function codeOnly(error: unknown): Error { + const code = /keystore:[a-z-]+/.exec(error instanceof Error ? error.message : String(error))?.[0]; + return new Error(code ?? 'keystore:failed'); +} + +export async function doKeystoreCall(plugin: InstalledPlugin, method: string, args: unknown[]): Promise { + const keystore = desktopKeystore(); + // A browser, a build without the helper, a plugin on an older shell. + if (!keystore) throw new Error('keystore:unsupported'); + + const run = (): Promise => { + switch (method) { + case 'keystore.list': + return keystore.list(); + case 'keystore.sign': + return keystore.sign(readId(args[0]), readBytes(args[1], MAX_DIGEST), checkedSignScheme(args[2]) as never); + case 'keystore.decrypt': + return keystore.decrypt(readId(args[0]), readBytes(args[1], MAX_CIPHERTEXT), checkedDecryptScheme(args[2]) as never); + case 'keystore.agree': + return keystore.agree(readId(args[0]), readBytes(args[1], MAX_PEER_KEY)); + default: + throw new Error('keystore:unsupported'); + } + }; + + // The OS may be showing a PIN dialog right now, which the host can't see. While it + // is, the plugin is waiting on the user, not hung: this holds the hook timer the + // way one of its own dialogs does, so a card's PIN during onRenderEmailBody doesn't + // count towards auto-disable. + const release = holdForUser(plugin.id); + try { + return await run(); + } catch (error) { + throw codeOnly(error); + } finally { + release(); + } +} diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index f67a9b59a..f00530fca 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -80,6 +80,7 @@ import { wkdAllowed } from '../outgoing-hosts'; import { noThirdPartyTraffic } from '../third-party-mode'; import { openExternalLink } from '../plugin-hook-result'; import { ASSISTANT_PERMS, NOT_MINE, dispatchAssistant } from './assistant'; +import { KEYSTORE_METHODS, KEYSTORE_PERMS, doKeystoreCall } from './host-api-keystore'; /** * Methods only callable from the privileged (same-origin) tier. These expose @@ -115,11 +116,14 @@ const PRIVILEGED_ONLY_METHODS = new Set([ // This entry used to read `upfiles.set`, which matches no dispatched method // and therefore gated nothing - the dispatcher calls it `upfiles.save`. 'upfiles.save', + // The OS certificate store: never for an untrusted plugin, whatever it declares. + ...KEYSTORE_METHODS, ]); export const PERM_PER_METHOD: Readonly> = { // The AI assistant's host APIs, one table per area (lib/plugin-sandbox/assistant). ...ASSISTANT_PERMS, + ...KEYSTORE_PERMS, // storage is unscoped by the manifest - implicit. 'storage.get': null, 'storage.set': null, @@ -2047,6 +2051,8 @@ export async function dispatchApiCall( if (assistant !== NOT_MINE) return assistant; } + if (method in KEYSTORE_PERMS) return doKeystoreCall(plugin, method, args); + switch (method) { case 'proxy.fetch': return proxyFetch(plugin, args, opts?.sink ?? null); case 'proxy.cancel': proxyCancel(plugin, args[0]); return undefined; diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index 9533ffc37..d99a2a634 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -15,6 +15,7 @@ import { withBasePath } from '../browser-navigation'; import { snapshotHostTheme, type ThemeSnapshot } from './host-theme'; import { snapshotHostFeatures, watchHostFeatures } from './host-features'; import { assistantFeatures } from './assistant'; +import { keystoreFeature } from './host-api-keystore'; import { effectiveSettings } from '../plugin-settings'; import { clampSlotHeight, clampSlotWidth, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout'; import { resolveDateLocale } from '../utils'; @@ -680,7 +681,7 @@ export class SandboxInstance { /** Push what api.host.features says now; before init it goes into the init payload. */ /** The flags of api.host.features that depend on this plugin. */ ownHostFeatures(): Partial { - return assistantFeatures(this.plugin); + return { ...assistantFeatures(this.plugin), ...keystoreFeature(this.plugin) }; } setHostFeatures(features: HostFeaturesView): void { diff --git a/lib/plugin-sandbox/host-dialog.ts b/lib/plugin-sandbox/host-dialog.ts index 500b4264f..d50008c98 100644 --- a/lib/plugin-sandbox/host-dialog.ts +++ b/lib/plugin-sandbox/host-dialog.ts @@ -171,9 +171,30 @@ export function cancelForPlugin(pluginId: string): void { if (changed) notify(); } -/** Whether the plugin has a dialog showing or waiting in the queue. */ +/** Calls waiting on a prompt the host can't see, per plugin (see holdForUser). */ +const userHolds = new Map(); + +/** + * The plugin is waiting on the user somewhere other than these dialogs, such as a + * smart card's PIN prompt from the OS. It counts as an open dialog for the hook timer + * until the returned function is called. Nothing is drawn for it. + */ +export function holdForUser(pluginId: string): () => void { + userHolds.set(pluginId, (userHolds.get(pluginId) ?? 0) + 1); + let released = false; + return () => { + if (released) return; + released = true; + const left = (userHolds.get(pluginId) ?? 1) - 1; + if (left > 0) userHolds.set(pluginId, left); + else userHolds.delete(pluginId); + notify(); + }; +} + +/** Whether the plugin has a dialog showing or waiting in the queue, or a hold. */ export function hasDialogFor(pluginId: string): boolean { - return queue.some((d) => d.pluginId === pluginId); + return userHolds.has(pluginId) || queue.some((d) => d.pluginId === pluginId); } /** diff --git a/lib/plugin-sandbox/host-features.ts b/lib/plugin-sandbox/host-features.ts index d8c6ba58c..50deeb4eb 100644 --- a/lib/plugin-sandbox/host-features.ts +++ b/lib/plugin-sandbox/host-features.ts @@ -14,6 +14,7 @@ import { noThirdPartyTraffic, subscribeThirdPartyMode } from '@/lib/third-party- import type { HostFeaturesView, InstalledPlugin } from '../plugin-types'; import type { OrganisationPolicy } from '@/lib/admin/organisation-policy'; import { assistantFeatures } from './assistant'; +import { keystoreFeature } from './host-api-keystore'; /** The external-mail-warning plugin, retired in favour of organisation.externalMail. */ export const EXTERNAL_MAIL_WARNING_PLUGIN_ID = 'external-mail-warning'; @@ -32,6 +33,7 @@ export function snapshotHostFeatures(plugin?: InstalledPlugin): HostFeaturesView ...hostFeaturesFor(usePolicyStore.getState().getOrganisation().externalMail, noThirdPartyTraffic()), // The AI assistant's host APIs this webmail offers (lib/plugin-sandbox/assistant). ...assistantFeatures(plugin), + ...keystoreFeature(plugin), }; } diff --git a/lib/plugin-sandbox/permission-text.ts b/lib/plugin-sandbox/permission-text.ts index fa044c762..6cac86eb4 100644 --- a/lib/plugin-sandbox/permission-text.ts +++ b/lib/plugin-sandbox/permission-text.ts @@ -11,6 +11,8 @@ import { ALL_PERMISSIONS, IMPLICIT_PERMISSIONS } from '../plugin-types'; */ export const HIGH_RISK_PERMISSIONS: ReadonlySet = new Set([ 'crypto:full', + // Signs and decrypts in the user's name with their certificates. + 'keystore', // email.get reads any message in the account, not only the open one. 'email:read', 'email:send', diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index cd69acd31..ff225df55 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -506,6 +506,23 @@ function buildCoreApi(manifest: PluginManifest) { return () => { hostFeatureListeners.delete(listener); }; }, }, + /** + * The OS certificate store and smart cards, in the desktop app. Privileged tier + * with the `keystore` permission, when api.host.features.keystore is true. No + * timeout: the OS may be showing a PIN prompt, and the host always answers. + * Errors are bare codes: keystore:cancelled, not-found, unsupported, locked, + * invalid, failed. + */ + keystore: { + /** Certificates fit for mail that have a private key. Never prompts. */ + list: () => callApi('keystore.list', [], 0), + /** Signs a digest. scheme: { scheme: 'rsa-pkcs1' | 'rsa-pss' | 'ecdsa', hash }. RSA gives the raw signature, ECDSA DER. */ + sign: (id: string, digest: Uint8Array, scheme: unknown) => callApi('keystore.sign', [id, digest, scheme], 0) as Promise, + /** Unwraps a content key. scheme: { scheme: 'rsa-pkcs1' } or { scheme: 'rsa-oaep', hash, label? }. */ + decrypt: (id: string, data: Uint8Array, scheme: unknown) => callApi('keystore.decrypt', [id, data, scheme], 0) as Promise, + /** ECDH: the raw shared secret, big-endian, from the peer's SubjectPublicKeyInfo. */ + agree: (id: string, peerPublicKey: Uint8Array) => callApi('keystore.agree', [id, peerPublicKey], 0) as Promise, + }, /** The app the plugin runs in: product, the administrator's name for it, version, edition. No permission needed. */ app: { getInfo: () => callApi('app.getInfo', []) as Promise, diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 646c93c4d..520c594d8 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -980,6 +980,12 @@ export interface HostFeaturesView extends AssistantHostFeatures { * down. */ noThirdPartyTraffic: boolean; + /** + * The desktop app's OS certificate store and smart cards are there for this + * plugin: privileged tier, the `keystore` permission, and a shell that has them. + * Absent otherwise. See `api.keystore`. + */ + keystore?: boolean; } /** One setting `api.app.searchSettings` found, in the user's language. */ @@ -1546,6 +1552,10 @@ export const ALL_PERMISSIONS = [ // message bodies and private keys; only granted to a signed, admin-approved // privileged bundle after explicit high-risk consent. 'crypto:full', + // Sign and decrypt with the OS certificate store and smart cards, in the desktop + // app. Declare it in optionalPermissions: as a plain permission it would be an + // install error on a webmail that doesn't know it. Privileged tier only. + 'keystore', // Submit a fully-formed raw RFC822 message via JMAP (used after a plugin // signs/encrypts an outgoing message itself). 'email:raw-send', diff --git a/locales/ar/common.json b/locales/ar/common.json index 91db0f8ea..110e14498 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5859,6 +5859,10 @@ "title": "وصول كامل إلى هذا التطبيق", "body": "تعمل بالصلاحيات نفسها التي يملكها تطبيق البريد. يمكنها قراءة كل بريدك ومفاتيحك المخزّنة والتصرف نيابةً عنك. لا تسمح بذلك إلا للإضافات التي تثق بها تمامًا." }, + "keystore": { + "title": "استخدام الشهادات الموجودة على هذا الكمبيوتر", + "body": "توقيع الرسائل وفك تشفيرها باستخدام الشهادات الموجودة في مخزن الشهادات بهذا الكمبيوتر أو على بطاقة ذكية. يبقى المفتاح الخاص هناك. قد يطلب منك النظام رقم PIN." + }, "email_raw_send": { "title": "إرسال رسائل أنشأتها بنفسها", "body": "إرسال رسائل كاملة من حسابك، مثل الرسائل الموقّعة أو المشفّرة." diff --git a/locales/ca/common.json b/locales/ca/common.json index d2e062a69..cdc203867 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5859,6 +5859,10 @@ "title": "Accés complet a aquesta aplicació", "body": "S'executa amb els mateixos drets que el mateix webmail. Pot llegir tot el vostre correu i les claus desades i actuar en nom vostre. Permeteu-ho només als connectors en què confieu plenament." }, + "keystore": { + "title": "Utilitzar els certificats d'aquest ordinador", + "body": "Signa i desxifra missatges amb certificats del magatzem de certificats d'aquest ordinador o d'una targeta intel·ligent. La clau privada es queda allà. El sistema us pot demanar el PIN." + }, "email_raw_send": { "title": "Enviar missatges que ha creat ell mateix", "body": "Enviar missatges complets, per exemple signats o xifrats, des del vostre compte." diff --git a/locales/cs/common.json b/locales/cs/common.json index 6b1e41754..bed597300 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5859,6 +5859,10 @@ "title": "Plný přístup k této aplikaci", "body": "Běží se stejnými právy jako samotný webmail. Může číst veškerou vaši poštu a uložené klíče a jednat vaším jménem. Povolte to jen pluginům, kterým plně důvěřujete." }, + "keystore": { + "title": "Používat certifikáty v tomto počítači", + "body": "Podepisuje a dešifruje zprávy pomocí certifikátů v úložišti certifikátů tohoto počítače nebo na čipové kartě. Soukromý klíč zůstává tam. Systém si může vyžádat váš PIN." + }, "email_raw_send": { "title": "Odesílat zprávy, které sám vytvořil", "body": "Odesílá z vašeho účtu kompletní zprávy, například podepsané nebo šifrované." diff --git a/locales/da/common.json b/locales/da/common.json index 61d6a1412..5b6f169df 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5859,6 +5859,10 @@ "title": "Fuld adgang til denne app", "body": "Kører med de samme rettigheder som selve webmailen. Det kan læse alle dine e-mails og gemte nøgler og handle på dine vegne. Tillad det kun for plugins, du stoler fuldt ud på." }, + "keystore": { + "title": "Brug certifikater på denne computer", + "body": "Signer og dekrypter beskeder med certifikater i computerens certifikatlager eller på et chipkort. Den private nøgle bliver, hvor den er. Systemet kan bede om din pinkode." + }, "email_raw_send": { "title": "Sende beskeder, det selv har lavet", "body": "Sende færdige beskeder, f.eks. signerede eller krypterede, fra din konto." diff --git a/locales/de/common.json b/locales/de/common.json index eb1ec9a29..ece5d1ee3 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5859,6 +5859,10 @@ "title": "Vollzugriff auf diese App", "body": "Läuft mit denselben Rechten wie das Webmail selbst. Es kann alle Ihre E-Mails und gespeicherten Schlüssel lesen und in Ihrem Namen handeln. Erlauben Sie das nur Plugins, denen Sie voll vertrauen." }, + "keystore": { + "title": "Zertifikate auf diesem Computer verwenden", + "body": "Nachrichten mit Zertifikaten aus dem Zertifikatspeicher dieses Computers oder von einer Smartcard signieren und entschlüsseln. Der private Schlüssel bleibt dort. Das System fragt eventuell nach Ihrer PIN." + }, "email_raw_send": { "title": "Selbst erstellte Nachrichten senden", "body": "Vollständige Nachrichten, zum Beispiel signierte oder verschlüsselte, von Ihrem Konto senden." diff --git a/locales/en/common.json b/locales/en/common.json index 0af0e143f..c2c8bf3cf 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5859,6 +5859,10 @@ "title": "Full access to this app", "body": "Runs with the same rights as the webmail itself. It can read all your mail and stored keys and act for you. Only allow this for plugins you fully trust." }, + "keystore": { + "title": "Use certificates on this computer", + "body": "Sign and decrypt messages with certificates in this computer's certificate store or on a smart card. The private key stays there. Your system may ask for your PIN." + }, "email_raw_send": { "title": "Send messages it built itself", "body": "Send complete messages, for example signed or encrypted ones, from your account." diff --git a/locales/es/common.json b/locales/es/common.json index 83aeead77..3696b169b 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5859,6 +5859,10 @@ "title": "Acceso total a esta aplicación", "body": "Se ejecuta con los mismos derechos que el propio webmail. Puede leer todo tu correo y tus claves guardadas y actuar en tu nombre. Permítelo solo en complementos en los que confíes plenamente." }, + "keystore": { + "title": "Usar certificados de este ordenador", + "body": "Firma y descifra mensajes con certificados del almacén de certificados de este ordenador o de una tarjeta inteligente. La clave privada se queda allí. El sistema puede pedirle su PIN." + }, "email_raw_send": { "title": "Enviar mensajes que creó él mismo", "body": "Enviar mensajes completos, por ejemplo firmados o cifrados, desde tu cuenta." diff --git a/locales/et/common.json b/locales/et/common.json index 788fb5171..ae1e731d9 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5859,6 +5859,10 @@ "title": "Täielik juurdepääs sellele rakendusele", "body": "Töötab samade õigustega kui veebimeil ise. See saab lugeda kogu sinu posti ja salvestatud võtmeid ning tegutseda sinu nimel. Luba see ainult pistikprogrammidele, mida täielikult usaldad." }, + "keystore": { + "title": "Kasuta selle arvuti sertifikaate", + "body": "Allkirjastab ja dekrüpteerib sõnumeid selle arvuti sertifikaadihoidlas või kiipkaardil olevate sertifikaatidega. Privaatvõti jääb sinna. Süsteem võib küsida teie PIN-koodi." + }, "email_raw_send": { "title": "Saada ise koostatud kirju", "body": "Saata sinu kontolt täielikke kirju, näiteks allkirjastatud või krüpteeritud." diff --git a/locales/fa/common.json b/locales/fa/common.json index 9eadf3199..a6918dcad 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5859,6 +5859,10 @@ "title": "دسترسی کامل به این برنامه", "body": "با همان دسترسی‌های خود ایمیل تحت وب اجرا می‌شود. می‌تواند همه ایمیل‌ها و کلیدهای ذخیره‌شده شما را بخواند و از طرف شما عمل کند. فقط برای افزونه‌هایی که کاملاً به آن‌ها اعتماد دارید اجازه دهید." }, + "keystore": { + "title": "استفاده از گواهی‌های این رایانه", + "body": "امضا و رمزگشایی پیام‌ها با گواهی‌های موجود در مخزن گواهی این رایانه یا روی کارت هوشمند. کلید خصوصی همان‌جا می‌ماند. ممکن است سیستم PIN شما را بخواهد." + }, "email_raw_send": { "title": "ارسال پیام‌هایی که خودش ساخته است", "body": "ارسال پیام‌های کامل، مثلاً امضاشده یا رمزنگاری‌شده، از حساب شما." diff --git a/locales/fr/common.json b/locales/fr/common.json index a44af9dbe..cd8e13285 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5859,6 +5859,10 @@ "title": "Accès complet à cette application", "body": "S'exécute avec les mêmes droits que le webmail lui-même. Il peut lire tous vos e-mails et vos clés enregistrées, et agir en votre nom. N'autorisez ceci que pour des plugins en qui vous avez entièrement confiance." }, + "keystore": { + "title": "Utiliser les certificats de cet ordinateur", + "body": "Signe et déchiffre des messages avec les certificats du magasin de certificats de cet ordinateur ou d'une carte à puce. La clé privée y reste. Le système peut vous demander votre code PIN." + }, "email_raw_send": { "title": "Envoyer des messages qu'il a lui-même créés", "body": "Envoyer des messages complets, par exemple signés ou chiffrés, depuis votre compte." diff --git a/locales/he/common.json b/locales/he/common.json index 17580b9a8..6d82ad4ac 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5860,6 +5860,10 @@ "title": "גישה מלאה לאפליקציה הזו", "body": "פועל עם אותן הרשאות כמו ה-Webmail עצמו. הוא יכול לקרוא את כל הדואר והמפתחות השמורים שלכם ולפעול בשמכם. אפשרו זאת רק לתוספים שאתם סומכים עליהם לגמרי." }, + "keystore": { + "title": "שימוש בתעודות במחשב זה", + "body": "חתימה על הודעות ופענוחן באמצעות תעודות ממאגר התעודות של המחשב או מכרטיס חכם. המפתח הפרטי נשאר שם. המערכת עשויה לבקש את קוד ה-PIN שלך." + }, "email_raw_send": { "title": "שליחת הודעות שהוא בנה בעצמו", "body": "שליחת הודעות שלמות, למשל חתומות או מוצפנות, מהחשבון שלכם." diff --git a/locales/hu/common.json b/locales/hu/common.json index 474772591..94032fde7 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5859,6 +5859,10 @@ "title": "Teljes hozzáférés az alkalmazáshoz", "body": "Ugyanazokkal a jogokkal fut, mint maga a webmail. Olvashatja az összes leveledet és tárolt kulcsodat, és a nevedben járhat el. Csak olyan bővítménynek engedélyezd, amelyben teljesen megbízol." }, + "keystore": { + "title": "A számítógépen lévő tanúsítványok használata", + "body": "Üzenetek aláírása és visszafejtése a számítógép tanúsítványtárolójában vagy intelligens kártyán lévő tanúsítványokkal. A titkos kulcs ott marad. A rendszer kérheti a PIN-kódját." + }, "email_raw_send": { "title": "Saját maga által összeállított üzenetek küldése", "body": "Teljes üzeneteket küld a fiókodból, például aláírt vagy titkosított üzeneteket." diff --git a/locales/it/common.json b/locales/it/common.json index 8cc39c364..2f38c56c9 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5859,6 +5859,10 @@ "title": "Accesso completo a questa app", "body": "Funziona con gli stessi diritti della webmail stessa. Può leggere tutta la tua posta e le chiavi salvate e agire per conto tuo. Consentilo solo ai plugin di cui ti fidi completamente." }, + "keystore": { + "title": "Usare i certificati di questo computer", + "body": "Firma e decifra i messaggi con i certificati nell'archivio certificati di questo computer o su una smart card. La chiave privata resta lì. Il sistema può chiedere il PIN." + }, "email_raw_send": { "title": "Inviare messaggi creati da sé", "body": "Inviare messaggi completi, ad esempio firmati o cifrati, dal tuo account." diff --git a/locales/ja/common.json b/locales/ja/common.json index e051ceb66..248e84546 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5859,6 +5859,10 @@ "title": "このアプリへのフルアクセス", "body": "ウェブメール本体と同じ権限で動作します。すべてのメールと保存された鍵を読み、あなたに代わって操作できます。完全に信頼できるプラグインにのみ許可してください。" }, + "keystore": { + "title": "このコンピューターの証明書を使用", + "body": "このコンピューターの証明書ストアまたはスマートカードにある証明書で、メッセージの署名と復号を行います。秘密鍵はそこから出ません。システムが PIN を求める場合があります。" + }, "email_raw_send": { "title": "自分で作成したメッセージを送信", "body": "署名済みや暗号化済みのメッセージなど、完成したメッセージをあなたのアカウントから送信できます。" diff --git a/locales/ko/common.json b/locales/ko/common.json index e7f7be907..1ae8135c0 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5859,6 +5859,10 @@ "title": "이 앱에 대한 전체 접근 권한", "body": "웹메일과 같은 권한으로 실행돼요. 모든 메일과 저장된 키를 읽고 사용자를 대신해 작업할 수 있어요. 완전히 신뢰하는 플러그인에만 허용하세요." }, + "keystore": { + "title": "이 컴퓨터의 인증서 사용", + "body": "이 컴퓨터의 인증서 저장소나 스마트 카드에 있는 인증서로 메시지에 서명하고 메시지를 복호화합니다. 개인 키는 그곳에 그대로 남습니다. 시스템에서 PIN을 요청할 수 있습니다." + }, "email_raw_send": { "title": "직접 만든 메시지 보내기", "body": "서명되거나 암호화된 메시지 같은 완성된 메시지를 내 계정에서 보낼 수 있어요." diff --git a/locales/lv/common.json b/locales/lv/common.json index c07fe9c31..4ae84ab2a 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5859,6 +5859,10 @@ "title": "Pilna piekļuve lietotnei", "body": "Darbojas ar tādām pašām tiesībām kā pats webmail. Var lasīt visu jūsu pastu un saglabātās atslēgas un rīkoties jūsu vārdā. Atļaujiet to tikai spraudņiem, kuriem pilnībā uzticaties." }, + "keystore": { + "title": "Izmantot šī datora sertifikātus", + "body": "Paraksta un atšifrē ziņojumus ar sertifikātiem, kas atrodas šī datora sertifikātu krātuvē vai viedkartē. Privātā atslēga paliek tur. Sistēma var prasīt jūsu PIN kodu." + }, "email_raw_send": { "title": "Sūtīt paša izveidotas vēstules", "body": "Sūtīt no jūsu konta pilnīgas vēstules, piemēram, parakstītas vai šifrētas." diff --git a/locales/mn/common.json b/locales/mn/common.json index 4633a5d6d..8711af780 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5859,6 +5859,10 @@ "title": "Аппад бүрэн хандах", "body": "Webmail-тэй адил эрхээр ажиллана. Таны бүх имэйл болон хадгалсан түлхүүрүүдийг уншиж, таны нэрийн өмнөөс үйлдэл хийж чадна. Зөвхөн бүрэн итгэдэг залгаасдаа үүнийг зөвшөөрнө үү." }, + "keystore": { + "title": "Энэ компьютер дээрх гэрчилгээг ашиглах", + "body": "Энэ компьютерийн гэрчилгээний сан эсвэл ухаалаг картан дээрх гэрчилгээгээр мессежид гарын үсэг зурж, тайлна. Хувийн түлхүүр тэнд хэвээр үлдэнэ. Систем таны PIN кодыг асууж магадгүй." + }, "email_raw_send": { "title": "Өөрөө бэлтгэсэн имэйл илгээх", "body": "Таны бүртгэлээс бүрэн бэлэн имэйл, жишээ нь гарын үсэг зурсан эсвэл шифрлэсэн имэйл илгээх." diff --git a/locales/nb/common.json b/locales/nb/common.json index d2e1a518e..3a961d552 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5859,6 +5859,10 @@ "title": "Full tilgang til denne appen", "body": "Kjører med de samme rettighetene som selve webmailen. Det kan lese all e-posten din og lagrede nøkler og handle på dine vegne. Tillat dette bare for tillegg du stoler fullt på." }, + "keystore": { + "title": "Bruk sertifikater på denne datamaskinen", + "body": "Signer og dekrypter meldinger med sertifikater i datamaskinens sertifikatlager eller på et smartkort. Den private nøkkelen blir værende der. Systemet kan be om PIN-koden din." + }, "email_raw_send": { "title": "Sende meldinger det har laget selv", "body": "Sende ferdige meldinger, for eksempel signerte eller krypterte, fra kontoen din." diff --git a/locales/nl/common.json b/locales/nl/common.json index b704a66fc..a6cf27d93 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5859,6 +5859,10 @@ "title": "Volledige toegang tot deze app", "body": "Draait met dezelfde rechten als de webmail zelf. Hij kan al je e-mail en opgeslagen sleutels lezen en namens jou handelen. Sta dit alleen toe voor plug-ins die je volledig vertrouwt." }, + "keystore": { + "title": "Certificaten op deze computer gebruiken", + "body": "Ondertekent en ontsleutelt berichten met certificaten uit het certificaatarchief van deze computer of van een smartcard. De privésleutel blijft daar. Het systeem kan om uw pincode vragen." + }, "email_raw_send": { "title": "Zelf samengestelde berichten verzenden", "body": "Volledige berichten, bijvoorbeeld ondertekende of versleutelde, vanaf je account verzenden." diff --git a/locales/pl/common.json b/locales/pl/common.json index 8ccc30784..7af5f1555 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5859,6 +5859,10 @@ "title": "Pełny dostęp do aplikacji", "body": "Działa z tymi samymi uprawnieniami co sam webmail. Może czytać całą Twoją pocztę i zapisane klucze oraz działać w Twoim imieniu. Zezwalaj na to tylko wtyczkom, którym w pełni ufasz." }, + "keystore": { + "title": "Używanie certyfikatów z tego komputera", + "body": "Podpisuje i odszyfrowuje wiadomości certyfikatami z magazynu certyfikatów tego komputera lub z karty inteligentnej. Klucz prywatny tam pozostaje. System może poprosić o kod PIN." + }, "email_raw_send": { "title": "Wysyłanie wiadomości utworzonych przez wtyczkę", "body": "Wysyła z Twojego konta kompletne wiadomości, na przykład podpisane lub zaszyfrowane." diff --git a/locales/pt/common.json b/locales/pt/common.json index 188b256f8..11b3b46d2 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5859,6 +5859,10 @@ "title": "Acesso total a este aplicativo", "body": "É executado com os mesmos direitos do próprio webmail. Pode ler todos os seus e-mails e chaves armazenadas e agir em seu nome. Só permita isto para plugins em que você confia totalmente." }, + "keystore": { + "title": "Usar certificados deste computador", + "body": "Assina e desencripta mensagens com certificados do repositório de certificados deste computador ou de um cartão inteligente. A chave privada fica lá. O sistema pode pedir o seu PIN." + }, "email_raw_send": { "title": "Enviar mensagens criadas por ele mesmo", "body": "Enviar mensagens completas, por exemplo assinadas ou criptografadas, a partir da sua conta." diff --git a/locales/ro/common.json b/locales/ro/common.json index ae31839c2..05c47d88e 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5859,6 +5859,10 @@ "title": "Acces complet la această aplicație", "body": "Rulează cu aceleași drepturi ca webmailul însuși. Poate citi toate e-mailurile și cheile stocate și poate acționa în numele dvs. Permiteți acest lucru doar pluginurilor în care aveți încredere deplină." }, + "keystore": { + "title": "Folosește certificatele de pe acest computer", + "body": "Semnează și decriptează mesaje cu certificatele din depozitul de certificate al acestui computer sau de pe un smart card. Cheia privată rămâne acolo. Sistemul vă poate cere codul PIN." + }, "email_raw_send": { "title": "Trimiterea mesajelor create de el însuși", "body": "Trimite mesaje complete, de exemplu semnate sau criptate, din contul dvs." diff --git a/locales/ru/common.json b/locales/ru/common.json index a9ee1c65c..792ef9656 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5859,6 +5859,10 @@ "title": "Полный доступ к приложению", "body": "Работает с теми же правами, что и сам webmail. Может читать всю вашу почту и сохранённые ключи и действовать от вашего имени. Разрешайте это только плагинам, которым полностью доверяете." }, + "keystore": { + "title": "Использовать сертификаты на этом компьютере", + "body": "Подписывает и расшифровывает сообщения сертификатами из хранилища сертификатов этого компьютера или со смарт-карты. Закрытый ключ остаётся там. Система может запросить ваш PIN-код." + }, "email_raw_send": { "title": "Отправлять собственные письма", "body": "Отправлять с вашего аккаунта готовые письма, например подписанные или зашифрованные." diff --git a/locales/sk/common.json b/locales/sk/common.json index a018ba55a..9e815645f 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5859,6 +5859,10 @@ "title": "Úplný prístup k tejto aplikácii", "body": "Beží s rovnakými právami ako samotný webmail. Môže čítať všetku vašu poštu a uložené kľúče a konať vo vašom mene. Povoľte to len doplnkom, ktorým úplne dôverujete." }, + "keystore": { + "title": "Používať certifikáty v tomto počítači", + "body": "Podpisuje a dešifruje správy pomocou certifikátov v úložisku certifikátov tohto počítača alebo na čipovej karte. Súkromný kľúč zostáva tam. Systém si môže vyžiadať váš PIN." + }, "email_raw_send": { "title": "Odosielať správy, ktoré sám vytvoril", "body": "Odosiela z vášho účtu kompletné správy, napríklad podpísané alebo šifrované." diff --git a/locales/th/common.json b/locales/th/common.json index 065a7afbc..ebca5eeb1 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2774,6 +2774,10 @@ "title": "สิทธิ์การเข้าถึงแอปพลิเคชันอย่างเต็มรูปแบบ", "body": "ทำงานด้วยสิทธิ์เดียวกันกับตัวเว็บเมลเอง สามารถอ่านอีเมลทั้งหมดและคีย์ลับที่จัดเก็บไว้ รวมถึงดำเนินการแทนคุณได้ อนุญาตเฉพาะปลั๊กอินที่คุณไว้วางใจอย่างสมบูรณ์เท่านั้น" }, + "keystore": { + "title": "ใช้ใบรับรองในคอมพิวเตอร์เครื่องนี้", + "body": "ลงนามและถอดรหัสข้อความด้วยใบรับรองในที่เก็บใบรับรองของคอมพิวเตอร์เครื่องนี้หรือบนสมาร์ตการ์ด คีย์ส่วนตัวจะอยู่ที่เดิม ระบบอาจขอ PIN ของคุณ" + }, "email_raw_send": { "title": "ส่งข้อความที่สร้างขึ้นเอง", "body": "ส่งข้อความฉบับสมบูรณ์ เช่น ข้อความที่มีการลงลายมือชื่อดิจิทัลหรือเข้ารหัส จากบัญชีของคุณ" diff --git a/locales/tr/common.json b/locales/tr/common.json index 81acedbf7..8baf80847 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5859,6 +5859,10 @@ "title": "Bu uygulamaya tam erişim", "body": "Webmail'in kendisiyle aynı haklarla çalışır. Tüm e-postalarınızı ve kayıtlı anahtarlarınızı okuyabilir, sizin adınıza işlem yapabilir. Buna yalnızca tamamen güvendiğiniz eklentiler için izin verin." }, + "keystore": { + "title": "Bu bilgisayardaki sertifikaları kullan", + "body": "İletileri bu bilgisayarın sertifika deposundaki veya akıllı karttaki sertifikalarla imzalar ve şifresini çözer. Özel anahtar orada kalır. Sistem PIN kodunuzu isteyebilir." + }, "email_raw_send": { "title": "Kendi oluşturduğu iletileri gönderme", "body": "Hesabınızdan imzalı veya şifreli iletiler gibi eksiksiz iletiler gönderme." diff --git a/locales/uk/common.json b/locales/uk/common.json index 42dbd1aba..165278c4c 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5859,6 +5859,10 @@ "title": "Повний доступ до програми", "body": "Працює з тими самими правами, що й сам webmail. Може читати всю вашу пошту та збережені ключі й діяти від вашого імені. Дозволяйте це лише плагінам, яким повністю довіряєте." }, + "keystore": { + "title": "Використовувати сертифікати на цьому комп'ютері", + "body": "Підписує та розшифровує повідомлення сертифікатами зі сховища сертифікатів цього комп'ютера або зі смарт-картки. Закритий ключ залишається там. Система може запитати ваш PIN-код." + }, "email_raw_send": { "title": "Надсилати власні листи", "body": "Надсилати з вашого облікового запису готові листи, наприклад підписані або зашифровані." diff --git a/locales/vi/common.json b/locales/vi/common.json index 9cbcd8bd3..a94c29c36 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5871,6 +5871,10 @@ "title": "Toàn quyền truy cập vào ứng dụng này", "body": "Chạy với các quyền tương tự như chính webmail. Nó có thể đọc tất cả thư và các khóa được lưu trữ của bạn và hành động thay bạn. Chỉ cho phép điều này đối với các plugin mà bạn hoàn toàn tin tưởng." }, + "keystore": { + "title": "Dùng chứng chỉ trên máy tính này", + "body": "Ký và giải mã thư bằng chứng chỉ trong kho chứng chỉ của máy tính này hoặc trên thẻ thông minh. Khóa riêng vẫn nằm ở đó. Hệ thống có thể yêu cầu mã PIN của bạn." + }, "email_raw_send": { "title": "Gửi thư nó tự tạo", "body": "Gửi thư hoàn chỉnh, ví dụ như thư đã ký hoặc mã hóa, từ tài khoản của bạn." diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 5ba44316f..67ab7f602 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5859,6 +5859,10 @@ "title": "完整存取此應用程式", "body": "以與網頁郵件本身相同的權限執行。它可以讀取您所有的郵件和已儲存的金鑰,並代表您執行動作。請只對您完全信任的外掛程式允許。" }, + "keystore": { + "title": "使用這台電腦上的憑證", + "body": "使用這台電腦憑證存放區或智慧卡中的憑證為郵件簽署及解密。私密金鑰會一直留在那裡。系統可能會要求輸入 PIN。" + }, "email_raw_send": { "title": "傳送它自行建立的郵件", "body": "從您的帳號傳送完整的郵件,例如已簽署或已加密的郵件。" diff --git a/locales/zh/common.json b/locales/zh/common.json index f53c82da4..0187b30bc 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5859,6 +5859,10 @@ "title": "完全访问此应用", "body": "以与网页邮箱本身相同的权限运行。它可以读取您的所有邮件和已存储的密钥,并代表您执行操作。请只对您完全信任的插件允许。" }, + "keystore": { + "title": "使用此电脑上的证书", + "body": "使用此电脑证书存储区或智能卡中的证书为邮件签名和解密。私钥始终保留在那里。系统可能会要求输入 PIN。" + }, "email_raw_send": { "title": "发送它自行构建的邮件", "body": "从您的账户发送完整的邮件,例如已签名或已加密的邮件。" From 1d4587f7ea145705ce4bf1c330a1fcc14536ff7b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:31:14 +0100 Subject: [PATCH 2/2] fix: stop a stuck certificate call holding mail rendering, and let a server's certificate access be taken back --- .../desktop-keystore-settings.test.tsx | 52 ++++++++++++++++++ .../settings/desktop-keystore-settings.tsx | 42 +++++++++++++++ components/settings/general-settings.tsx | 2 + lib/__tests__/plugin-keystore-runtime.test.ts | 14 +++-- lib/__tests__/plugin-keystore.test.ts | 53 +++++++++++++++++++ lib/desktop/types.ts | 16 ++++-- lib/plugin-hooks.ts | 17 ++++++ lib/plugin-sandbox/host-api-keystore.ts | 12 +++-- lib/plugin-sandbox/runtime.tsx | 13 ++--- locales/ar/common.json | 10 +++- locales/ca/common.json | 10 +++- locales/cs/common.json | 10 +++- locales/da/common.json | 10 +++- locales/de/common.json | 10 +++- locales/en/common.json | 10 +++- locales/es/common.json | 10 +++- locales/et/common.json | 10 +++- locales/fa/common.json | 10 +++- locales/fr/common.json | 10 +++- locales/he/common.json | 10 +++- locales/hu/common.json | 10 +++- locales/it/common.json | 10 +++- locales/ja/common.json | 10 +++- locales/ko/common.json | 10 +++- locales/lv/common.json | 10 +++- locales/mn/common.json | 10 +++- locales/nb/common.json | 10 +++- locales/nl/common.json | 10 +++- locales/pl/common.json | 10 +++- locales/pt/common.json | 10 +++- locales/ro/common.json | 10 +++- locales/ru/common.json | 10 +++- locales/sk/common.json | 10 +++- locales/th/common.json | 10 +++- locales/tr/common.json | 10 +++- locales/uk/common.json | 10 +++- locales/vi/common.json | 10 +++- locales/zh-TW/common.json | 10 +++- locales/zh/common.json | 10 +++- 39 files changed, 474 insertions(+), 47 deletions(-) create mode 100644 components/settings/__tests__/desktop-keystore-settings.test.tsx create mode 100644 components/settings/desktop-keystore-settings.tsx diff --git a/components/settings/__tests__/desktop-keystore-settings.test.tsx b/components/settings/__tests__/desktop-keystore-settings.test.tsx new file mode 100644 index 000000000..3a48141cb --- /dev/null +++ b/components/settings/__tests__/desktop-keystore-settings.test.tsx @@ -0,0 +1,52 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DesktopKeystoreSettings } from '../desktop-keystore-settings'; + +// Taking back a server's use of the certificates on this computer. The stored yes lives +// in the shell; this row is the way out, and only shows when there is something to take back. + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() })); +vi.mock('@/stores/toast-store', () => ({ toast })); +vi.mock('../settings-section', async (importOriginal) => ({ + ...(await importOriginal()), + SettingsSection: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +function bridge(state: string) { + const revokeAccess = vi.fn(async () => {}); + (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = { + version: 1, + platform: 'win32', + keystore: { accessState: vi.fn(async () => state), revokeAccess }, + }; + return revokeAccess; +} + +afterEach(() => { + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; + toast.success.mockClear(); +}); + +describe('the keystore access row', () => { + it('takes back a stored yes through the shell, and goes away', async () => { + const revoke = bridge('stored'); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'revoke' })); + await waitFor(() => expect(revoke).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(screen.queryByRole('button', { name: 'revoke' })).toBeNull()); + expect(toast.success).toHaveBeenCalledWith('revoked'); + }); + + it.each(['off', 'policy', 'app'])('shows nothing when the state is %s', async (state) => { + bridge(state); + const { container } = render(); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(container.textContent).toBe(''); + }); + + it('shows nothing in a browser', async () => { + const { container } = render(); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(container.textContent).toBe(''); + }); +}); diff --git a/components/settings/desktop-keystore-settings.tsx b/components/settings/desktop-keystore-settings.tsx new file mode 100644 index 000000000..381f502ab --- /dev/null +++ b/components/settings/desktop-keystore-settings.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { toast } from "@/stores/toast-store"; +import { SettingsSection, SettingItem } from "./settings-section"; + +/** + * In the desktop app on an instance: the user's yes to this server using certificates on + * this computer, stored in the shell, and the way to take it back. Nothing shows in a + * browser, in standalone, or when an organisation's policy set it. + */ +export function DesktopKeystoreSettings() { + const t = useTranslations("desktop_keystore"); + const [stored, setStored] = useState(false); + + useEffect(() => { + let live = true; + window.bulwarkDesktop?.keystore?.accessState?.().then( + (state) => { if (live) setStored(state === "stored"); }, + () => {}, + ); + return () => { live = false; }; + }, []); + + if (!stored) return null; + + const revoke = async () => { + await window.bulwarkDesktop?.keystore?.revokeAccess(); + setStored(false); + toast.success(t("revoked")); + }; + + return ( + + + + + + ); +} diff --git a/components/settings/general-settings.tsx b/components/settings/general-settings.tsx index 41d2ba072..0a03272bf 100644 --- a/components/settings/general-settings.tsx +++ b/components/settings/general-settings.tsx @@ -4,6 +4,7 @@ import { useTranslations } from 'next-intl'; import { useSettingsStore } from '@/stores/settings-store'; import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { LanguageSettings } from './language-settings'; +import { DesktopKeystoreSettings } from './desktop-keystore-settings'; import { ProtocolHandlerSettings } from './protocol-handler-settings'; import { useTour } from '@/components/tour/tour-provider'; import { Button } from '@/components/ui/button'; @@ -25,6 +26,7 @@ export function GeneralSettings({ supportsCalendar }: { supportsCalendar: boolea {!IS_LITE && } + diff --git a/lib/__tests__/plugin-keystore-runtime.test.ts b/lib/__tests__/plugin-keystore-runtime.test.ts index d50e1b172..ab1d59dec 100644 --- a/lib/__tests__/plugin-keystore-runtime.test.ts +++ b/lib/__tests__/plugin-keystore-runtime.test.ts @@ -71,21 +71,29 @@ describe('api.keystore in the sandbox', () => { expect(g.__pluginApi!.host.features.keystore).toBeFalsy(); }, 30_000); - it('no call gives up while the host has not answered: a PIN can take minutes', async () => { + it('a list has the ordinary RPC timeout: it never waits on a card', async () => { + await boot({ keystore: true }); + vi.useFakeTimers(); + const listing = g.__pluginApi!.keystore.list(); + const outcome = listing.then(() => 'answered', (error: Error) => error.message); + await vi.advanceTimersByTimeAsync(31_000); + await expect(outcome).resolves.toMatch(/timed out/); + }, 30_000); + + it('no key operation gives up while the host has not answered: a PIN can take minutes', async () => { await boot({ keystore: true }); vi.useFakeTimers(); const api = g.__pluginApi!.keystore; const digest = new Uint8Array(32); const settled: string[] = []; const watch = (name: string, promise: Promise) => promise.then(() => settled.push(name), () => settled.push(`${name} rejected`)); - watch('list', api.list()); watch('sign', api.sign('id', digest, { scheme: 'rsa-pkcs1', hash: 'SHA-256' })); watch('decrypt', api.decrypt('id', new Uint8Array(256), { scheme: 'rsa-pkcs1' })); watch('agree', api.agree('id', new Uint8Array(91))); // Past the RPC's 30 s and past the network calls' 120 s. await vi.advanceTimersByTimeAsync(600_000); expect(settled).toEqual([]); - expect(requests().map((request) => request.method)).toEqual(['keystore.list', 'keystore.sign', 'keystore.decrypt', 'keystore.agree']); + expect(requests().map((request) => request.method)).toEqual(['keystore.sign', 'keystore.decrypt', 'keystore.agree']); }, 30_000); it('passes the bytes as bytes, and hands an error back with the code as its message', async () => { diff --git a/lib/__tests__/plugin-keystore.test.ts b/lib/__tests__/plugin-keystore.test.ts index e8109181b..3a1426d92 100644 --- a/lib/__tests__/plugin-keystore.test.ts +++ b/lib/__tests__/plugin-keystore.test.ts @@ -281,6 +281,59 @@ describe('a keystore call and the hook timer', () => { expect(hasDialogFor(ID)).toBe(false); }); + it('a list takes no hold: it never waits on a card, and a hung list must not stop the hook timer', async () => { + const bridge = fakeBridge(); + bridge.list.mockImplementation(() => new Promise(() => {})); + installBridge(bridge); + renderHooks.onRenderEmailBody.register(ID, async (body: object) => { + await dispatchApiCall(plugin(), 'keystore.list', [], { privileged: true }); + return { ...body, html: 'never' }; + }); + const ran = renderHooks.onRenderEmailBody.transform({ html: 'as it was' }); + expect(hasDialogFor(ID)).toBe(false); + await vi.advanceTimersByTimeAsync(6000); + await expect(ran).resolves.toEqual({ html: 'as it was' }); + expect(console.error).toHaveBeenCalled(); + }); + + it('a call that never answers cannot hold the hook for ever: there is an absolute deadline', async () => { + const bridge = fakeBridge(); + bridge.decrypt.mockImplementation(() => new Promise(() => {})); + installBridge(bridge); + // This call never ends, so its hold never does: an id of its own. + const hung = plugin({ id: 'hung-plugin' }); + renderHooks.onRenderEmailBody.register(hung.id, async (body: object) => { + await dispatchApiCall(hung, 'keystore.decrypt', ['id', new Uint8Array(256), { scheme: 'rsa-pkcs1' }], { privileged: true }); + return { ...body, html: 'never' }; + }); + const ran = renderHooks.onRenderEmailBody.transform({ html: 'as it was' }); + let settled = false; + void ran.then(() => { settled = true; }); + // Longer than the shell's own 5 minute cap, which would normally end it first. + await vi.advanceTimersByTimeAsync(5 * 60_000); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(11 * 60_000); + expect(settled).toBe(true); + await expect(ran).resolves.toEqual({ html: 'as it was' }); + expect(console.error).toHaveBeenCalled(); + }); + + it('holds that keep coming and going do not reset the deadline', async () => { + renderHooks.onRenderEmailBody.register(ID, () => new Promise(() => {})); + let release = holdForUser(ID); + const ran = renderHooks.onRenderEmailBody.transform({ html: 'as it was' }); + let settled = false; + void ran.then(() => { settled = true; }); + for (let minute = 0; minute < 20 && !settled; minute += 1) { + await vi.advanceTimersByTimeAsync(4000); + release(); + release = holdForUser(ID); + await vi.advanceTimersByTimeAsync(60_000 - 4000); + } + release(); + expect(settled).toBe(true); + }); + it('a hook that hangs with no keystore call still times out', async () => { renderHooks.onRenderEmailBody.register(ID, () => new Promise(() => {})); const ran = renderHooks.onRenderEmailBody.transform({ html: 'as it was' }); diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 4f6502313..5c60da8bf 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -141,8 +141,13 @@ export interface DesktopKeystoreCertificate { signSchemes: string[]; decryptSchemes: string[]; canAgree: boolean; + /** The key can also sign the user in somewhere: the user is asked per use. */ + askEachUse: boolean; } +/** Whether this origin may use the keystore at all. `app` is standalone. */ +export type DesktopKeystoreAccessState = 'app' | 'policy' | 'stored' | 'off'; + export type DesktopKeystoreSignScheme = | { scheme: 'rsa-pkcs1'; hash: DesktopKeystoreHash } /** MGF1 on the same hash, salt as long as the hash. */ @@ -158,11 +163,13 @@ export type DesktopKeystoreDecryptScheme = /** * The OS certificate store and smart cards. Present only where the native helper * loaded and the OS has one. Failures reject with a bare code as the message: - * keystore:cancelled, not-found, unsupported, locked, invalid, failed. The key never - * leaves the OS, and its own prompts stay on, so a call may take as long as a PIN. + * keystore:cancelled, not-found, unsupported, locked, invalid, failed. The shell asks the + * user in a native dialog before any of this, on top of which the OS has its own prompts, + * so a call may take a while. On an instance it stays off until policy or the user allows + * it. The key never leaves the OS. */ export interface DesktopKeystoreBridge { - /** Certificates fit for mail that have a private key. Never prompts. */ + /** Certificates fit for mail that have a private key. The OS doesn't prompt. */ list: () => Promise; /** RSA gives the raw signature, ECDSA a DER ECDSA-Sig-Value. */ sign: (id: string, digest: Uint8Array, scheme: DesktopKeystoreSignScheme) => Promise; @@ -170,6 +177,9 @@ export interface DesktopKeystoreBridge { decrypt: (id: string, data: Uint8Array, scheme: DesktopKeystoreDecryptScheme) => Promise; /** The raw shared secret, big-endian. The peer's key is an EC SubjectPublicKeyInfo. */ agree: (id: string, peerPublicKey: Uint8Array) => Promise; + accessState: () => Promise; + /** Takes back a stored yes and this session's grants. Only ever narrows. */ + revokeAccess: () => Promise; } export interface BulwarkDesktopBridge { diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index 655ff4695..6686e7e37 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -71,6 +71,13 @@ const INTERCEPT_TIMEOUT_MS = 60_000; // counted them toward auto-disable. const BLOB_UPLOAD_TIMEOUT_MS = 15 * 60_000; +/** + * The longest a hook may stay waiting on a dialog or an OS prompt, from its start. A + * person answers well within this. A plugin whose dialog is never answered, or a call the + * OS never finishes, must not hold mail rendering or a send forever. + */ +const HELD_HOOK_DEADLINE_MS = 10 * 60_000; + class HookTimeoutError extends Error {} /** Tells the hook timer when a plugin is waiting on the user. */ @@ -97,20 +104,30 @@ function withTimeout(pluginId: string, promise: T | Promise, ms: number = if (!(promise instanceof Promise)) return Promise.resolve(promise); return new Promise((resolve, reject) => { let timer: ReturnType | undefined; + let deadline: ReturnType | undefined; let stopWaiting: (() => void) | undefined; + const started = Date.now(); const arm = () => { stopWaiting = undefined; timer = setTimeout(expire, ms); }; const expire = () => { if (dialogGate?.hasOpenDialog(pluginId)) { + // One deadline from the start, however often holds come and go. A bus with a + // longer budget of its own has at least that. + deadline ??= setTimeout(() => { + settle(); + reject(new HookTimeoutError(`Hook still waiting on the user after ${Date.now() - started}ms`)); + }, Math.max(0, started + Math.max(HELD_HOOK_DEADLINE_MS, ms) - Date.now())); stopWaiting = dialogGate.whenDialogsClosed(pluginId, arm); return; } + clearTimeout(deadline); reject(new HookTimeoutError(`Hook timed out after ${ms}ms`)); }; const settle = () => { clearTimeout(timer); + clearTimeout(deadline); stopWaiting?.(); }; arm(); diff --git a/lib/plugin-sandbox/host-api-keystore.ts b/lib/plugin-sandbox/host-api-keystore.ts index cb05d215b..f292cd6ad 100644 --- a/lib/plugin-sandbox/host-api-keystore.ts +++ b/lib/plugin-sandbox/host-api-keystore.ts @@ -3,9 +3,9 @@ // private key never reaches the webmail, and the OS's own prompts stay on. // // Privileged tier only (host-api.ts), so a user-uploaded plugin never gets here. -// A privileged frame is same-origin and can reach window.parent.bulwarkDesktop -// directly; these checks are for the front door, and the safeguards that hold in -// both cases are in the shell and the OS. +// None of this is a security boundary: a privileged frame is same-origin and can reach +// window.parent.bulwarkDesktop directly. The checks are for the front door. What holds in +// both cases is in the shell: a native dialog in main (keystore-consent.ts) and the OS. // // RPC errors reach the frame as message strings only, so the code is the message: // keystore:cancelled, keystore:not-found, keystore:unsupported, keystore:locked, @@ -118,8 +118,10 @@ export async function doKeystoreCall(plugin: InstalledPlugin, method: string, ar // The OS may be showing a PIN dialog right now, which the host can't see. While it // is, the plugin is waiting on the user, not hung: this holds the hook timer the // way one of its own dialogs does, so a card's PIN during onRenderEmailBody doesn't - // count towards auto-disable. - const release = holdForUser(plugin.id); + // count towards auto-disable. Not for a list: it never touches a card, and a plugin + // shouldn't be able to stop the timer with it. The hold has an absolute deadline + // (plugin-hooks.ts), and the shell gives up on a stuck call before that. + const release = method === 'keystore.list' ? () => {} : holdForUser(plugin.id); try { return await run(); } catch (error) { diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index ff225df55..ed18e5a4e 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -508,14 +508,15 @@ function buildCoreApi(manifest: PluginManifest) { }, /** * The OS certificate store and smart cards, in the desktop app. Privileged tier - * with the `keystore` permission, when api.host.features.keystore is true. No - * timeout: the OS may be showing a PIN prompt, and the host always answers. - * Errors are bare codes: keystore:cancelled, not-found, unsupported, locked, - * invalid, failed. + * with the `keystore` permission, when api.host.features.keystore is true. The + * shell asks the user first, so any call can answer keystore:cancelled. Key + * operations have no timeout here: the OS may be showing a PIN prompt, and the shell + * gives up by itself. Errors are bare codes: keystore:cancelled, not-found, + * unsupported, locked, invalid, failed. */ keystore: { - /** Certificates fit for mail that have a private key. Never prompts. */ - list: () => callApi('keystore.list', [], 0), + /** Certificates fit for mail that have a private key. The OS doesn't prompt, and it has the usual timeout. */ + list: () => callApi('keystore.list', []), /** Signs a digest. scheme: { scheme: 'rsa-pkcs1' | 'rsa-pss' | 'ecdsa', hash }. RSA gives the raw signature, ECDSA DER. */ sign: (id: string, digest: Uint8Array, scheme: unknown) => callApi('keystore.sign', [id, digest, scheme], 0) as Promise, /** Unwraps a content key. scheme: { scheme: 'rsa-pkcs1' } or { scheme: 'rsa-oaep', hash, label? }. */ diff --git a/locales/ar/common.json b/locales/ar/common.json index 110e14498..1b0d2ba26 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "استخدام الشهادات الموجودة على هذا الكمبيوتر", - "body": "توقيع الرسائل وفك تشفيرها باستخدام الشهادات الموجودة في مخزن الشهادات بهذا الكمبيوتر أو على بطاقة ذكية. يبقى المفتاح الخاص هناك. قد يطلب منك النظام رقم PIN." + "body": "طلب توقيع الرسائل وفك تشفيرها باستخدام الشهادات الموجودة على هذا الكمبيوتر أو على بطاقة ذكية. يسألك التطبيق أولاً. لا يحصل المكوّن الإضافي على المفتاح الخاص أبدًا، ولكن طالما سمحت بذلك يمكنه التوقيع باسمك وقراءة ما يفك تشفيره." }, "email_raw_send": { "title": "إرسال رسائل أنشأتها بنفسها", @@ -6513,5 +6513,13 @@ "server_needed": "أدخل عنوان الخادم أولًا.", "server_declined": "لم تسمح بهذا الخادم، لذا لم يُرسل إليه أي شيء.", "server_invalid": "عنوان الخادم هذا غير صالح." + }, + "desktop_keystore": { + "title": "الشهادات الموجودة على هذا الكمبيوتر", + "description": "يمكن لهذا الخادم أن يطلب استخدام شهاداتك وبطاقاتك الذكية. سيُطلب إذنك مع ذلك قبل استخدام كل شهادة.", + "label": "وصول هذا الخادم", + "label_description": "اسحب إذنك. سيتعين على الخادم أن يطلبه مرة أخرى.", + "revoke": "سحب الإذن", + "revoked": "تم سحب الإذن" } } diff --git a/locales/ca/common.json b/locales/ca/common.json index cdc203867..441e45d82 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "Utilitzar els certificats d'aquest ordinador", - "body": "Signa i desxifra missatges amb certificats del magatzem de certificats d'aquest ordinador o d'una targeta intel·ligent. La clau privada es queda allà. El sistema us pot demanar el PIN." + "body": "Demana signar i desxifrar missatges amb certificats d'aquest ordinador o d'una targeta intel·ligent. L'aplicació us ho pregunta abans. El connector no rep mai la clau privada, però mentre ho permeteu pot signar en nom vostre i llegir el que desxifra." }, "email_raw_send": { "title": "Enviar missatges que ha creat ell mateix", @@ -6513,5 +6513,13 @@ "server_needed": "Primer escriu l'adreça del servidor.", "server_declined": "No has permès aquest servidor, així que no s'hi ha enviat res.", "server_invalid": "Aquesta adreça de servidor no és vàlida." + }, + "desktop_keystore": { + "title": "Certificats d'aquest ordinador", + "description": "Aquest servidor pot demanar utilitzar els vostres certificats i targetes intel·ligents. Tot i així, se us preguntarà abans d'utilitzar cada certificat.", + "label": "Accés d'aquest servidor", + "label_description": "Retira el permís. El servidor ho haurà de tornar a demanar.", + "revoke": "Retira", + "revoked": "Permís retirat" } } diff --git a/locales/cs/common.json b/locales/cs/common.json index bed597300..936c0ea36 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "Používat certifikáty v tomto počítači", - "body": "Podepisuje a dešifruje zprávy pomocí certifikátů v úložišti certifikátů tohoto počítače nebo na čipové kartě. Soukromý klíč zůstává tam. Systém si může vyžádat váš PIN." + "body": "Žádá o podepisování a dešifrování zpráv pomocí certifikátů v tomto počítači nebo na čipové kartě. Aplikace se vás nejprve zeptá. Plugin nikdy nedostane soukromý klíč, ale dokud to povolíte, může podepisovat vaším jménem a číst, co dešifruje." }, "email_raw_send": { "title": "Odesílat zprávy, které sám vytvořil", @@ -6513,5 +6513,13 @@ "server_needed": "Nejdřív zadejte adresu serveru.", "server_declined": "Tento server jste nepovolili, proto se na něj nic neodeslalo.", "server_invalid": "Tato adresa serveru není platná." + }, + "desktop_keystore": { + "title": "Certifikáty v tomto počítači", + "description": "Tento server může žádat o použití vašich certifikátů a čipových karet. Před použitím každého certifikátu se vás přesto zeptáme.", + "label": "Přístup tohoto serveru", + "label_description": "Odvolejte své povolení. Server se bude muset zeptat znovu.", + "revoke": "Odvolat", + "revoked": "Povolení bylo odvoláno" } } diff --git a/locales/da/common.json b/locales/da/common.json index 5b6f169df..cac3d466f 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "Brug certifikater på denne computer", - "body": "Signer og dekrypter beskeder med certifikater i computerens certifikatlager eller på et chipkort. Den private nøgle bliver, hvor den er. Systemet kan bede om din pinkode." + "body": "Beder om at signere og dekryptere beskeder med certifikater på denne computer eller et chipkort. Appen spørger dig først. Pluginnet får aldrig den private nøgle, men så længe du tillader det, kan det signere i dit navn og læse det, det dekrypterer." }, "email_raw_send": { "title": "Sende beskeder, det selv har lavet", @@ -6513,5 +6513,13 @@ "server_needed": "Angiv først serverens adresse.", "server_declined": "Du tillod ikke denne server, så der blev ikke sendt noget til den.", "server_invalid": "Denne serveradresse er ikke gyldig." + }, + "desktop_keystore": { + "title": "Certifikater på denne computer", + "description": "Denne server kan bede om at bruge dine certifikater og chipkort. Du bliver stadig spurgt, før hvert certifikat bruges.", + "label": "Adgang for denne server", + "label_description": "Træk din tilladelse tilbage. Serveren skal så spørge igen.", + "revoke": "Træk tilbage", + "revoked": "Tilladelsen er trukket tilbage" } } diff --git a/locales/de/common.json b/locales/de/common.json index ece5d1ee3..e24af8ec3 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "Zertifikate auf diesem Computer verwenden", - "body": "Nachrichten mit Zertifikaten aus dem Zertifikatspeicher dieses Computers oder von einer Smartcard signieren und entschlüsseln. Der private Schlüssel bleibt dort. Das System fragt eventuell nach Ihrer PIN." + "body": "Bittet darum, Nachrichten mit Zertifikaten auf diesem Computer oder einer Smartcard zu signieren und zu entschlüsseln. Die App fragt Sie vorher. Das Plugin erhält nie den privaten Schlüssel, kann aber, solange Sie es zulassen, in Ihrem Namen signieren und lesen, was es entschlüsselt." }, "email_raw_send": { "title": "Selbst erstellte Nachrichten senden", @@ -6513,5 +6513,13 @@ "server_needed": "Geben Sie zuerst die Adresse Ihres Servers ein.", "server_declined": "Sie haben diesen Server nicht zugelassen, daher wurde nichts an ihn gesendet.", "server_invalid": "Diese Serveradresse ist ungültig." + }, + "desktop_keystore": { + "title": "Zertifikate auf diesem Computer", + "description": "Dieser Server darf darum bitten, Ihre Zertifikate und Smartcards zu verwenden. Vor der Verwendung eines Zertifikats werden Sie trotzdem gefragt.", + "label": "Zugriff für diesen Server", + "label_description": "Nehmen Sie Ihre Erlaubnis zurück. Der Server muss dann erneut fragen.", + "revoke": "Zurücknehmen", + "revoked": "Erlaubnis zurückgenommen" } } diff --git a/locales/en/common.json b/locales/en/common.json index c2c8bf3cf..7d3d177e9 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "Use certificates on this computer", - "body": "Sign and decrypt messages with certificates in this computer's certificate store or on a smart card. The private key stays there. Your system may ask for your PIN." + "body": "Ask to sign and decrypt messages with certificates on this computer or a smart card. The app asks you first. The plugin never gets the private key, but while you allow it, it can sign in your name and read what it decrypts." }, "email_raw_send": { "title": "Send messages it built itself", @@ -6513,5 +6513,13 @@ "server_needed": "Enter your server's address first.", "server_declined": "You didn't allow this server, so nothing was sent to it.", "server_invalid": "This server address isn't valid." + }, + "desktop_keystore": { + "title": "Certificates on this computer", + "description": "This server may ask to use your certificates and smart cards. You are still asked before each certificate is used.", + "label": "Access for this server", + "label_description": "Take back your permission. The server has to ask again.", + "revoke": "Take back", + "revoked": "Permission taken back" } } diff --git a/locales/es/common.json b/locales/es/common.json index 3696b169b..c003b697d 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "Usar certificados de este ordenador", - "body": "Firma y descifra mensajes con certificados del almacén de certificados de este ordenador o de una tarjeta inteligente. La clave privada se queda allí. El sistema puede pedirle su PIN." + "body": "Pide firmar y descifrar mensajes con certificados de este ordenador o de una tarjeta inteligente. La aplicación le pregunta antes. El complemento nunca recibe la clave privada, pero mientras lo permita puede firmar en su nombre y leer lo que descifra." }, "email_raw_send": { "title": "Enviar mensajes que creó él mismo", @@ -6513,5 +6513,13 @@ "server_needed": "Primero escribe la dirección del servidor.", "server_declined": "No permitiste este servidor, así que no se le envió nada.", "server_invalid": "Esta dirección de servidor no es válida." + }, + "desktop_keystore": { + "title": "Certificados de este ordenador", + "description": "Este servidor puede pedir usar sus certificados y tarjetas inteligentes. Aun así, se le preguntará antes de usar cada certificado.", + "label": "Acceso de este servidor", + "label_description": "Retire su permiso. El servidor tendrá que volver a pedirlo.", + "revoke": "Retirar", + "revoked": "Permiso retirado" } } diff --git a/locales/et/common.json b/locales/et/common.json index ae1e731d9..3384c90a4 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "Kasuta selle arvuti sertifikaate", - "body": "Allkirjastab ja dekrüpteerib sõnumeid selle arvuti sertifikaadihoidlas või kiipkaardil olevate sertifikaatidega. Privaatvõti jääb sinna. Süsteem võib küsida teie PIN-koodi." + "body": "Palub sõnumeid allkirjastada ja dekrüpteerida selle arvuti või kiipkaardi sertifikaatidega. Rakendus küsib teilt enne. Pistikprogramm ei saa kunagi privaatvõtit, kuid seni, kuni seda lubate, saab see teie nimel allkirjastada ja lugeda seda, mida dekrüpteerib." }, "email_raw_send": { "title": "Saada ise koostatud kirju", @@ -6513,5 +6513,13 @@ "server_needed": "Sisesta kõigepealt serveri aadress.", "server_declined": "Sa ei lubanud seda serverit, nii et sellele ei saadetud midagi.", "server_invalid": "See serveri aadress ei kehti." + }, + "desktop_keystore": { + "title": "Selle arvuti sertifikaadid", + "description": "See server võib paluda teie sertifikaate ja kiipkaarte kasutada. Enne iga sertifikaadi kasutamist küsitakse teilt siiski luba.", + "label": "Selle serveri juurdepääs", + "label_description": "Võtke oma luba tagasi. Server peab siis uuesti küsima.", + "revoke": "Võta tagasi", + "revoked": "Luba võeti tagasi" } } diff --git a/locales/fa/common.json b/locales/fa/common.json index a6918dcad..ee6eb5279 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "استفاده از گواهی‌های این رایانه", - "body": "امضا و رمزگشایی پیام‌ها با گواهی‌های موجود در مخزن گواهی این رایانه یا روی کارت هوشمند. کلید خصوصی همان‌جا می‌ماند. ممکن است سیستم PIN شما را بخواهد." + "body": "درخواست امضا و رمزگشایی پیام‌ها با گواهی‌های این رایانه یا کارت هوشمند. برنامه ابتدا از شما می‌پرسد. افزونه هرگز کلید خصوصی را دریافت نمی‌کند، اما تا زمانی که اجازه دهید می‌تواند به نام شما امضا کند و آنچه را رمزگشایی می‌کند بخواند." }, "email_raw_send": { "title": "ارسال پیام‌هایی که خودش ساخته است", @@ -6513,5 +6513,13 @@ "server_needed": "ابتدا نشانی سرور را وارد کنید.", "server_declined": "شما این سرور را مجاز نکردید، پس چیزی به آن فرستاده نشد.", "server_invalid": "این نشانی سرور معتبر نیست." + }, + "desktop_keystore": { + "title": "گواهی‌های این رایانه", + "description": "این سرور می‌تواند برای استفاده از گواهی‌ها و کارت‌های هوشمند شما درخواست دهد. با این حال پیش از استفاده از هر گواهی از شما پرسیده می‌شود.", + "label": "دسترسی این سرور", + "label_description": "اجازهٔ خود را پس بگیرید. سرور باید دوباره درخواست کند.", + "revoke": "پس گرفتن", + "revoked": "اجازه پس گرفته شد" } } diff --git a/locales/fr/common.json b/locales/fr/common.json index cd8e13285..3de5a0882 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "Utiliser les certificats de cet ordinateur", - "body": "Signe et déchiffre des messages avec les certificats du magasin de certificats de cet ordinateur ou d'une carte à puce. La clé privée y reste. Le système peut vous demander votre code PIN." + "body": "Demande à signer et déchiffrer des messages avec les certificats de cet ordinateur ou d'une carte à puce. L'application vous pose d'abord la question. Le plugin ne reçoit jamais la clé privée, mais tant que vous l'autorisez, il peut signer en votre nom et lire ce qu'il déchiffre." }, "email_raw_send": { "title": "Envoyer des messages qu'il a lui-même créés", @@ -6513,5 +6513,13 @@ "server_needed": "Saisissez d'abord l'adresse du serveur.", "server_declined": "Vous n'avez pas autorisé ce serveur, donc rien ne lui a été envoyé.", "server_invalid": "Cette adresse de serveur n'est pas valide." + }, + "desktop_keystore": { + "title": "Certificats de cet ordinateur", + "description": "Ce serveur peut demander à utiliser vos certificats et cartes à puce. La question vous est quand même posée avant l'utilisation de chaque certificat.", + "label": "Accès de ce serveur", + "label_description": "Retirez votre autorisation. Le serveur devra la redemander.", + "revoke": "Retirer", + "revoked": "Autorisation retirée" } } diff --git a/locales/he/common.json b/locales/he/common.json index 6d82ad4ac..477e7f931 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5862,7 +5862,7 @@ }, "keystore": { "title": "שימוש בתעודות במחשב זה", - "body": "חתימה על הודעות ופענוחן באמצעות תעודות ממאגר התעודות של המחשב או מכרטיס חכם. המפתח הפרטי נשאר שם. המערכת עשויה לבקש את קוד ה-PIN שלך." + "body": "מבקש לחתום על הודעות ולפענח אותן באמצעות תעודות במחשב זה או בכרטיס חכם. האפליקציה שואלת אותך קודם. התוסף אף פעם לא מקבל את המפתח הפרטי, אבל כל עוד אתה מאפשר זאת הוא יכול לחתום בשמך ולקרוא את מה שהוא מפענח." }, "email_raw_send": { "title": "שליחת הודעות שהוא בנה בעצמו", @@ -6514,5 +6514,13 @@ "server_needed": "קודם צריך להזין את כתובת השרת.", "server_declined": "לא אישרת את השרת הזה, לכן לא נשלח אליו דבר.", "server_invalid": "כתובת השרת הזו לא תקינה." + }, + "desktop_keystore": { + "title": "תעודות במחשב זה", + "description": "שרת זה יכול לבקש להשתמש בתעודות ובכרטיסים החכמים שלך. עדיין תישאל לפני השימוש בכל תעודה.", + "label": "גישה לשרת זה", + "label_description": "בטל את ההרשאה שלך. השרת יצטרך לבקש שוב.", + "revoke": "ביטול", + "revoked": "ההרשאה בוטלה" } } diff --git a/locales/hu/common.json b/locales/hu/common.json index 94032fde7..e360b332a 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "A számítógépen lévő tanúsítványok használata", - "body": "Üzenetek aláírása és visszafejtése a számítógép tanúsítványtárolójában vagy intelligens kártyán lévő tanúsítványokkal. A titkos kulcs ott marad. A rendszer kérheti a PIN-kódját." + "body": "Kéri, hogy üzeneteket írhasson alá és fejthessen vissza a számítógépen vagy intelligens kártyán lévő tanúsítványokkal. Az alkalmazás előbb megkérdezi Önt. A bővítmény soha nem kapja meg a titkos kulcsot, de amíg engedélyezi, az Ön nevében írhat alá, és elolvashatja, amit visszafejt." }, "email_raw_send": { "title": "Saját maga által összeállított üzenetek küldése", @@ -6513,5 +6513,13 @@ "server_needed": "Először adja meg a szerver címét.", "server_declined": "Nem engedélyezte ezt a szervert, ezért semmit sem küldtünk neki.", "server_invalid": "Ez a szervercím nem érvényes." + }, + "desktop_keystore": { + "title": "A számítógépen lévő tanúsítványok", + "description": "Ez a kiszolgáló kérheti a tanúsítványai és intelligens kártyái használatát. Minden tanúsítvány használata előtt így is megkérdezzük.", + "label": "A kiszolgáló hozzáférése", + "label_description": "Vonja vissza az engedélyét. A kiszolgálónak újra kérnie kell.", + "revoke": "Visszavonás", + "revoked": "Engedély visszavonva" } } diff --git a/locales/it/common.json b/locales/it/common.json index 2f38c56c9..76552fee9 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "Usare i certificati di questo computer", - "body": "Firma e decifra i messaggi con i certificati nell'archivio certificati di questo computer o su una smart card. La chiave privata resta lì. Il sistema può chiedere il PIN." + "body": "Chiede di firmare e decifrare i messaggi con i certificati di questo computer o di una smart card. L'app te lo chiede prima. Il plugin non riceve mai la chiave privata, ma finché lo consenti può firmare a tuo nome e leggere ciò che decifra." }, "email_raw_send": { "title": "Inviare messaggi creati da sé", @@ -6513,5 +6513,13 @@ "server_needed": "Inserisci prima l'indirizzo del server.", "server_declined": "Non hai autorizzato questo server, quindi non gli è stato inviato nulla.", "server_invalid": "Questo indirizzo del server non è valido." + }, + "desktop_keystore": { + "title": "Certificati di questo computer", + "description": "Questo server può chiedere di usare i tuoi certificati e le tue smart card. Ti verrà comunque chiesto prima di usare ogni certificato.", + "label": "Accesso di questo server", + "label_description": "Ritira il tuo permesso. Il server dovrà chiederlo di nuovo.", + "revoke": "Ritira", + "revoked": "Permesso ritirato" } } diff --git a/locales/ja/common.json b/locales/ja/common.json index 248e84546..a749005a6 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "このコンピューターの証明書を使用", - "body": "このコンピューターの証明書ストアまたはスマートカードにある証明書で、メッセージの署名と復号を行います。秘密鍵はそこから出ません。システムが PIN を求める場合があります。" + "body": "このコンピューターまたはスマートカードの証明書でメッセージの署名と復号を行うよう求めます。アプリが先に確認します。プラグインが秘密鍵を受け取ることはありませんが、許可している間はあなたの名前で署名し、復号した内容を読むことができます。" }, "email_raw_send": { "title": "自分で作成したメッセージを送信", @@ -6513,5 +6513,13 @@ "server_needed": "先にサーバーのアドレスを入力してください。", "server_declined": "このサーバーを許可しなかったため、何も送信していません。", "server_invalid": "このサーバーアドレスは正しくありません。" + }, + "desktop_keystore": { + "title": "このコンピューターの証明書", + "description": "このサーバーは、証明書とスマートカードの使用を求めることができます。各証明書を使用する前には、引き続き確認されます。", + "label": "このサーバーのアクセス", + "label_description": "許可を取り消します。サーバーは再度確認する必要があります。", + "revoke": "取り消す", + "revoked": "許可を取り消しました" } } diff --git a/locales/ko/common.json b/locales/ko/common.json index 1ae8135c0..91250257a 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "이 컴퓨터의 인증서 사용", - "body": "이 컴퓨터의 인증서 저장소나 스마트 카드에 있는 인증서로 메시지에 서명하고 메시지를 복호화합니다. 개인 키는 그곳에 그대로 남습니다. 시스템에서 PIN을 요청할 수 있습니다." + "body": "이 컴퓨터나 스마트 카드의 인증서로 메시지 서명과 복호화를 요청합니다. 앱이 먼저 사용자에게 묻습니다. 플러그인은 개인 키를 받지 않지만, 허용하는 동안에는 사용자 이름으로 서명하고 복호화한 내용을 읽을 수 있습니다." }, "email_raw_send": { "title": "직접 만든 메시지 보내기", @@ -6513,5 +6513,13 @@ "server_needed": "먼저 서버 주소를 입력하세요.", "server_declined": "이 서버를 허용하지 않아 아무것도 보내지 않았습니다.", "server_invalid": "이 서버 주소는 올바르지 않습니다." + }, + "desktop_keystore": { + "title": "이 컴퓨터의 인증서", + "description": "이 서버는 인증서와 스마트 카드 사용을 요청할 수 있습니다. 각 인증서를 사용하기 전에는 여전히 사용자에게 묻습니다.", + "label": "이 서버의 액세스", + "label_description": "허용을 취소합니다. 서버는 다시 요청해야 합니다.", + "revoke": "취소", + "revoked": "허용을 취소했습니다" } } diff --git a/locales/lv/common.json b/locales/lv/common.json index 4ae84ab2a..16b5ed5d5 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "Izmantot šī datora sertifikātus", - "body": "Paraksta un atšifrē ziņojumus ar sertifikātiem, kas atrodas šī datora sertifikātu krātuvē vai viedkartē. Privātā atslēga paliek tur. Sistēma var prasīt jūsu PIN kodu." + "body": "Lūdz parakstīt un atšifrēt ziņojumus ar šī datora vai viedkartes sertifikātiem. Lietotne vispirms jautā jums. Spraudnis nekad nesaņem privāto atslēgu, taču, kamēr to atļaujat, tas var parakstīt jūsu vārdā un lasīt to, ko atšifrē." }, "email_raw_send": { "title": "Sūtīt paša izveidotas vēstules", @@ -6513,5 +6513,13 @@ "server_needed": "Vispirms ievadiet servera adresi.", "server_declined": "Jūs neatļāvāt šo serveri, tāpēc tam nekas netika nosūtīts.", "server_invalid": "Šī servera adrese nav derīga." + }, + "desktop_keystore": { + "title": "Šī datora sertifikāti", + "description": "Šis serveris var lūgt izmantot jūsu sertifikātus un viedkartes. Pirms katra sertifikāta izmantošanas jums tomēr tiks jautāts.", + "label": "Šī servera piekļuve", + "label_description": "Atsauciet savu atļauju. Serverim būs jālūdz vēlreiz.", + "revoke": "Atsaukt", + "revoked": "Atļauja atsaukta" } } diff --git a/locales/mn/common.json b/locales/mn/common.json index 8711af780..449d09058 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "Энэ компьютер дээрх гэрчилгээг ашиглах", - "body": "Энэ компьютерийн гэрчилгээний сан эсвэл ухаалаг картан дээрх гэрчилгээгээр мессежид гарын үсэг зурж, тайлна. Хувийн түлхүүр тэнд хэвээр үлдэнэ. Систем таны PIN кодыг асууж магадгүй." + "body": "Энэ компьютер эсвэл ухаалаг картан дээрх гэрчилгээгээр мессежид гарын үсэг зурах, тайлахыг хүснэ. Апп эхлээд танаас асууна. Өргөтгөл хувийн түлхүүрийг хэзээ ч авахгүй, гэхдээ таныг зөвшөөрч байх хугацаанд таны нэрээр гарын үсэг зурж, тайлсан зүйлээ уншиж чадна." }, "email_raw_send": { "title": "Өөрөө бэлтгэсэн имэйл илгээх", @@ -6513,5 +6513,13 @@ "server_needed": "Эхлээд серверийн хаягаа оруулна уу.", "server_declined": "Та энэ серверийг зөвшөөрөөгүй тул юу ч илгээгээгүй.", "server_invalid": "Энэ серверийн хаяг буруу байна." + }, + "desktop_keystore": { + "title": "Энэ компьютер дээрх гэрчилгээ", + "description": "Энэ сервер таны гэрчилгээ, ухаалаг картыг ашиглахыг хүсэж болно. Гэрчилгээ бүрийг ашиглахын өмнө танаас асуусаар байх болно.", + "label": "Энэ серверийн хандалт", + "label_description": "Зөвшөөрлөө буцааж авна уу. Сервер дахин асуух шаардлагатай.", + "revoke": "Буцааж авах", + "revoked": "Зөвшөөрлийг буцааж авлаа" } } diff --git a/locales/nb/common.json b/locales/nb/common.json index 3a961d552..922d02942 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "Bruk sertifikater på denne datamaskinen", - "body": "Signer og dekrypter meldinger med sertifikater i datamaskinens sertifikatlager eller på et smartkort. Den private nøkkelen blir værende der. Systemet kan be om PIN-koden din." + "body": "Ber om å signere og dekryptere meldinger med sertifikater på denne datamaskinen eller et smartkort. Appen spør deg først. Tillegget får aldri den private nøkkelen, men så lenge du tillater det, kan det signere i ditt navn og lese det det dekrypterer." }, "email_raw_send": { "title": "Sende meldinger det har laget selv", @@ -6513,5 +6513,13 @@ "server_needed": "Skriv inn serveradressen først.", "server_declined": "Du tillot ikke denne serveren, så ingenting ble sendt til den.", "server_invalid": "Denne serveradressen er ikke gyldig." + }, + "desktop_keystore": { + "title": "Sertifikater på denne datamaskinen", + "description": "Denne serveren kan be om å bruke sertifikatene og smartkortene dine. Du blir likevel spurt før hvert sertifikat brukes.", + "label": "Tilgang for denne serveren", + "label_description": "Trekk tilbake tillatelsen din. Serveren må da spørre på nytt.", + "revoke": "Trekk tilbake", + "revoked": "Tillatelsen er trukket tilbake" } } diff --git a/locales/nl/common.json b/locales/nl/common.json index a6cf27d93..c8d450062 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "Certificaten op deze computer gebruiken", - "body": "Ondertekent en ontsleutelt berichten met certificaten uit het certificaatarchief van deze computer of van een smartcard. De privésleutel blijft daar. Het systeem kan om uw pincode vragen." + "body": "Vraagt om berichten te ondertekenen en te ontsleutelen met certificaten op deze computer of een smartcard. De app vraagt het u eerst. De plug-in krijgt nooit de privésleutel, maar zolang u het toestaat, kan hij in uw naam ondertekenen en lezen wat hij ontsleutelt." }, "email_raw_send": { "title": "Zelf samengestelde berichten verzenden", @@ -6513,5 +6513,13 @@ "server_needed": "Vul eerst het adres van de server in.", "server_declined": "Je hebt deze server niet toegestaan, dus er is niets naartoe gestuurd.", "server_invalid": "Dit serveradres is niet geldig." + }, + "desktop_keystore": { + "title": "Certificaten op deze computer", + "description": "Deze server kan vragen om uw certificaten en smartcards te gebruiken. U krijgt nog steeds een vraag voordat een certificaat wordt gebruikt.", + "label": "Toegang voor deze server", + "label_description": "Trek uw toestemming in. De server moet dan opnieuw vragen.", + "revoke": "Intrekken", + "revoked": "Toestemming ingetrokken" } } diff --git a/locales/pl/common.json b/locales/pl/common.json index 7af5f1555..e2a7d2ed7 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "Używanie certyfikatów z tego komputera", - "body": "Podpisuje i odszyfrowuje wiadomości certyfikatami z magazynu certyfikatów tego komputera lub z karty inteligentnej. Klucz prywatny tam pozostaje. System może poprosić o kod PIN." + "body": "Prosi o podpisywanie i odszyfrowywanie wiadomości certyfikatami z tego komputera lub karty inteligentnej. Aplikacja najpierw zapyta. Wtyczka nigdy nie dostaje klucza prywatnego, ale dopóki na to pozwalasz, może podpisywać w Twoim imieniu i czytać to, co odszyfruje." }, "email_raw_send": { "title": "Wysyłanie wiadomości utworzonych przez wtyczkę", @@ -6513,5 +6513,13 @@ "server_needed": "Najpierw wpisz adres serwera.", "server_declined": "Ten serwer nie został dozwolony, więc nic do niego nie wysłano.", "server_invalid": "Ten adres serwera jest nieprawidłowy." + }, + "desktop_keystore": { + "title": "Certyfikaty na tym komputerze", + "description": "Ten serwer może prosić o użycie Twoich certyfikatów i kart inteligentnych. Przed użyciem każdego certyfikatu nadal zostaniesz zapytany.", + "label": "Dostęp tego serwera", + "label_description": "Cofnij swoją zgodę. Serwer będzie musiał zapytać ponownie.", + "revoke": "Cofnij", + "revoked": "Zgoda została cofnięta" } } diff --git a/locales/pt/common.json b/locales/pt/common.json index 11b3b46d2..3c08c38b8 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "Usar certificados deste computador", - "body": "Assina e desencripta mensagens com certificados do repositório de certificados deste computador ou de um cartão inteligente. A chave privada fica lá. O sistema pode pedir o seu PIN." + "body": "Pede para assinar e desencriptar mensagens com certificados deste computador ou de um cartão inteligente. A aplicação pergunta-lhe primeiro. O plugin nunca recebe a chave privada, mas enquanto o permitir pode assinar em seu nome e ler o que desencripta." }, "email_raw_send": { "title": "Enviar mensagens criadas por ele mesmo", @@ -6513,5 +6513,13 @@ "server_needed": "Digite primeiro o endereço do servidor.", "server_declined": "Você não permitiu este servidor, então nada foi enviado a ele.", "server_invalid": "Este endereço de servidor não é válido." + }, + "desktop_keystore": { + "title": "Certificados deste computador", + "description": "Este servidor pode pedir para usar os seus certificados e cartões inteligentes. Mesmo assim, ser-lhe-á perguntado antes de cada certificado ser usado.", + "label": "Acesso deste servidor", + "label_description": "Retire a sua permissão. O servidor terá de pedir novamente.", + "revoke": "Retirar", + "revoked": "Permissão retirada" } } diff --git a/locales/ro/common.json b/locales/ro/common.json index 05c47d88e..1d2e29bdd 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "Folosește certificatele de pe acest computer", - "body": "Semnează și decriptează mesaje cu certificatele din depozitul de certificate al acestui computer sau de pe un smart card. Cheia privată rămâne acolo. Sistemul vă poate cere codul PIN." + "body": "Cere să semneze și să decripteze mesaje cu certificate de pe acest computer sau de pe un smart card. Aplicația vă întreabă mai întâi. Pluginul nu primește niciodată cheia privată, dar cât timp permiteți poate semna în numele dvs. și poate citi ce decriptează." }, "email_raw_send": { "title": "Trimiterea mesajelor create de el însuși", @@ -6513,5 +6513,13 @@ "server_needed": "Introdu mai întâi adresa serverului.", "server_declined": "Nu ai permis acest server, așa că nu i s-a trimis nimic.", "server_invalid": "Această adresă de server nu este validă." + }, + "desktop_keystore": { + "title": "Certificate de pe acest computer", + "description": "Acest server poate cere să vă folosească certificatele și smart cardurile. Veți fi întrebat în continuare înainte de folosirea fiecărui certificat.", + "label": "Accesul acestui server", + "label_description": "Retrageți permisiunea. Serverul va trebui să ceară din nou.", + "revoke": "Retrage", + "revoked": "Permisiune retrasă" } } diff --git a/locales/ru/common.json b/locales/ru/common.json index 792ef9656..59e1c6a28 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "Использовать сертификаты на этом компьютере", - "body": "Подписывает и расшифровывает сообщения сертификатами из хранилища сертификатов этого компьютера или со смарт-карты. Закрытый ключ остаётся там. Система может запросить ваш PIN-код." + "body": "Запрашивает подпись и расшифровку сообщений сертификатами на этом компьютере или смарт-карте. Сначала приложение спросит вас. Плагин никогда не получает закрытый ключ, но пока вы разрешаете, он может подписывать от вашего имени и читать то, что расшифровывает." }, "email_raw_send": { "title": "Отправлять собственные письма", @@ -6513,5 +6513,13 @@ "server_needed": "Сначала введите адрес сервера.", "server_declined": "Вы не разрешили этот сервер, поэтому ему ничего не отправлено.", "server_invalid": "Этот адрес сервера недействителен." + }, + "desktop_keystore": { + "title": "Сертификаты на этом компьютере", + "description": "Этот сервер может запрашивать использование ваших сертификатов и смарт-карт. Перед использованием каждого сертификата вас всё равно спросят.", + "label": "Доступ для этого сервера", + "label_description": "Отзовите своё разрешение. Серверу придётся запросить его снова.", + "revoke": "Отозвать", + "revoked": "Разрешение отозвано" } } diff --git a/locales/sk/common.json b/locales/sk/common.json index 9e815645f..94ce2d1e8 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "Používať certifikáty v tomto počítači", - "body": "Podpisuje a dešifruje správy pomocou certifikátov v úložisku certifikátov tohto počítača alebo na čipovej karte. Súkromný kľúč zostáva tam. Systém si môže vyžiadať váš PIN." + "body": "Žiada o podpisovanie a dešifrovanie správ pomocou certifikátov v tomto počítači alebo na čipovej karte. Aplikácia sa vás najprv opýta. Doplnok nikdy nedostane súkromný kľúč, ale kým to povolíte, môže podpisovať vaším menom a čítať, čo dešifruje." }, "email_raw_send": { "title": "Odosielať správy, ktoré sám vytvoril", @@ -6513,5 +6513,13 @@ "server_needed": "Najprv zadajte adresu servera.", "server_declined": "Tento server ste nepovolili, preto sa naň nič neodoslalo.", "server_invalid": "Táto adresa servera nie je platná." + }, + "desktop_keystore": { + "title": "Certifikáty v tomto počítači", + "description": "Tento server môže žiadať o použitie vašich certifikátov a čipových kariet. Pred použitím každého certifikátu sa vás aj tak opýtame.", + "label": "Prístup tohto servera", + "label_description": "Odvolajte svoje povolenie. Server sa bude musieť opýtať znova.", + "revoke": "Odvolať", + "revoked": "Povolenie bolo odvolané" } } diff --git a/locales/th/common.json b/locales/th/common.json index ebca5eeb1..039a179cb 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2776,7 +2776,7 @@ }, "keystore": { "title": "ใช้ใบรับรองในคอมพิวเตอร์เครื่องนี้", - "body": "ลงนามและถอดรหัสข้อความด้วยใบรับรองในที่เก็บใบรับรองของคอมพิวเตอร์เครื่องนี้หรือบนสมาร์ตการ์ด คีย์ส่วนตัวจะอยู่ที่เดิม ระบบอาจขอ PIN ของคุณ" + "body": "ขอลงนามและถอดรหัสข้อความด้วยใบรับรองในคอมพิวเตอร์เครื่องนี้หรือบนสมาร์ตการ์ด แอปจะถามคุณก่อน ปลั๊กอินไม่ได้รับคีย์ส่วนตัว แต่ตราบใดที่คุณอนุญาต ปลั๊กอินจะลงนามในนามของคุณและอ่านสิ่งที่ถอดรหัสได้" }, "email_raw_send": { "title": "ส่งข้อความที่สร้างขึ้นเอง", @@ -6521,5 +6521,13 @@ "server_needed": "ป้อนที่อยู่เซิร์ฟเวอร์ก่อน", "server_declined": "คุณไม่ได้อนุญาตเซิร์ฟเวอร์นี้ จึงไม่มีการส่งข้อมูลใดไป", "server_invalid": "ที่อยู่เซิร์ฟเวอร์นี้ไม่ถูกต้อง" + }, + "desktop_keystore": { + "title": "ใบรับรองในคอมพิวเตอร์เครื่องนี้", + "description": "เซิร์ฟเวอร์นี้สามารถขอใช้ใบรับรองและสมาร์ตการ์ดของคุณได้ คุณจะยังถูกถามก่อนใช้ใบรับรองแต่ละใบ", + "label": "การเข้าถึงของเซิร์ฟเวอร์นี้", + "label_description": "ถอนการอนุญาตของคุณ เซิร์ฟเวอร์จะต้องขออีกครั้ง", + "revoke": "ถอน", + "revoked": "ถอนการอนุญาตแล้ว" } } diff --git a/locales/tr/common.json b/locales/tr/common.json index 8baf80847..80ac19f74 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "Bu bilgisayardaki sertifikaları kullan", - "body": "İletileri bu bilgisayarın sertifika deposundaki veya akıllı karttaki sertifikalarla imzalar ve şifresini çözer. Özel anahtar orada kalır. Sistem PIN kodunuzu isteyebilir." + "body": "Bu bilgisayardaki veya akıllı karttaki sertifikalarla iletileri imzalamak ve şifresini çözmek ister. Uygulama önce size sorar. Eklenti özel anahtarı asla almaz, ancak siz izin verdiğiniz sürece adınıza imzalayabilir ve şifresini çözdüğünü okuyabilir." }, "email_raw_send": { "title": "Kendi oluşturduğu iletileri gönderme", @@ -6513,5 +6513,13 @@ "server_needed": "Önce sunucu adresini girin.", "server_declined": "Bu sunucuya izin vermediniz, bu yüzden ona hiçbir şey gönderilmedi.", "server_invalid": "Bu sunucu adresi geçerli değil." + }, + "desktop_keystore": { + "title": "Bu bilgisayardaki sertifikalar", + "description": "Bu sunucu, sertifikalarınızı ve akıllı kartlarınızı kullanmak isteyebilir. Her sertifika kullanılmadan önce yine de size sorulur.", + "label": "Bu sunucunun erişimi", + "label_description": "İzninizi geri alın. Sunucunun yeniden sorması gerekir.", + "revoke": "Geri al", + "revoked": "İzin geri alındı" } } diff --git a/locales/uk/common.json b/locales/uk/common.json index 165278c4c..e7736acc3 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "Використовувати сертифікати на цьому комп'ютері", - "body": "Підписує та розшифровує повідомлення сертифікатами зі сховища сертифікатів цього комп'ютера або зі смарт-картки. Закритий ключ залишається там. Система може запитати ваш PIN-код." + "body": "Просить підписувати та розшифровувати повідомлення сертифікатами на цьому комп'ютері або смарт-картці. Спершу застосунок запитає вас. Плагін ніколи не отримує закритий ключ, але поки ви дозволяєте, він може підписувати від вашого імені та читати те, що розшифровує." }, "email_raw_send": { "title": "Надсилати власні листи", @@ -6513,5 +6513,13 @@ "server_needed": "Спочатку введіть адресу сервера.", "server_declined": "Ви не дозволили цей сервер, тому йому нічого не надіслано.", "server_invalid": "Ця адреса сервера недійсна." + }, + "desktop_keystore": { + "title": "Сертифікати на цьому комп'ютері", + "description": "Цей сервер може просити використовувати ваші сертифікати та смарт-картки. Перед використанням кожного сертифіката вас однаково запитають.", + "label": "Доступ для цього сервера", + "label_description": "Відкличте свій дозвіл. Серверу доведеться запитати знову.", + "revoke": "Відкликати", + "revoked": "Дозвіл відкликано" } } diff --git a/locales/vi/common.json b/locales/vi/common.json index a94c29c36..05952bcb1 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5873,7 +5873,7 @@ }, "keystore": { "title": "Dùng chứng chỉ trên máy tính này", - "body": "Ký và giải mã thư bằng chứng chỉ trong kho chứng chỉ của máy tính này hoặc trên thẻ thông minh. Khóa riêng vẫn nằm ở đó. Hệ thống có thể yêu cầu mã PIN của bạn." + "body": "Yêu cầu ký và giải mã thư bằng chứng chỉ trên máy tính này hoặc thẻ thông minh. Ứng dụng sẽ hỏi bạn trước. Tiện ích không bao giờ nhận được khóa riêng, nhưng khi bạn cho phép, tiện ích có thể ký dưới tên bạn và đọc nội dung đã giải mã." }, "email_raw_send": { "title": "Gửi thư nó tự tạo", @@ -6525,5 +6525,13 @@ "server_needed": "Trước tiên hãy nhập địa chỉ máy chủ.", "server_declined": "Bạn không cho phép máy chủ này nên không có gì được gửi đến đó.", "server_invalid": "Địa chỉ máy chủ này không hợp lệ." + }, + "desktop_keystore": { + "title": "Chứng chỉ trên máy tính này", + "description": "Máy chủ này có thể yêu cầu dùng chứng chỉ và thẻ thông minh của bạn. Bạn vẫn được hỏi trước khi mỗi chứng chỉ được dùng.", + "label": "Quyền truy cập của máy chủ này", + "label_description": "Thu hồi quyền của bạn. Máy chủ sẽ phải hỏi lại.", + "revoke": "Thu hồi", + "revoked": "Đã thu hồi quyền" } } diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 67ab7f602..7b78c8dcc 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "使用這台電腦上的憑證", - "body": "使用這台電腦憑證存放區或智慧卡中的憑證為郵件簽署及解密。私密金鑰會一直留在那裡。系統可能會要求輸入 PIN。" + "body": "要求使用這台電腦或智慧卡上的憑證為郵件簽署及解密。應用程式會先詢問你。外掛程式絕不會取得私密金鑰,但在你允許期間,它可以用你的名義簽署,並讀取它解密的內容。" }, "email_raw_send": { "title": "傳送它自行建立的郵件", @@ -6513,5 +6513,13 @@ "server_needed": "請先輸入伺服器位址。", "server_declined": "你沒有允許此伺服器,因此沒有傳送任何內容給它。", "server_invalid": "此伺服器位址無效。" + }, + "desktop_keystore": { + "title": "這台電腦上的憑證", + "description": "此伺服器可以要求使用你的憑證和智慧卡。每次使用憑證前仍會詢問你。", + "label": "此伺服器的存取權", + "label_description": "收回你的許可。伺服器需要重新詢問。", + "revoke": "收回", + "revoked": "已收回許可" } } diff --git a/locales/zh/common.json b/locales/zh/common.json index 0187b30bc..52c1f2f43 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5861,7 +5861,7 @@ }, "keystore": { "title": "使用此电脑上的证书", - "body": "使用此电脑证书存储区或智能卡中的证书为邮件签名和解密。私钥始终保留在那里。系统可能会要求输入 PIN。" + "body": "请求使用此电脑或智能卡上的证书为邮件签名和解密。应用会先询问你。插件永远拿不到私钥,但在你允许期间,它可以以你的名义签名,并读取它解密的内容。" }, "email_raw_send": { "title": "发送它自行构建的邮件", @@ -6513,5 +6513,13 @@ "server_needed": "请先输入服务器地址。", "server_declined": "你没有允许此服务器,因此没有向它发送任何内容。", "server_invalid": "此服务器地址无效。" + }, + "desktop_keystore": { + "title": "此电脑上的证书", + "description": "此服务器可以请求使用你的证书和智能卡。每次使用证书前仍会询问你。", + "label": "此服务器的访问权限", + "label_description": "收回你的许可。服务器需要重新询问。", + "revoke": "收回", + "revoked": "已收回许可" } }