feat: merge the plugin keystore capability

This commit is contained in:
Linus Rath
2026-11-03 11:41:30 +01:00
46 changed files with 1205 additions and 3 deletions
@@ -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<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
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(<DesktopKeystoreSettings />);
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(<DesktopKeystoreSettings />);
await new Promise((resolve) => setTimeout(resolve, 5));
expect(container.textContent).toBe('');
});
it('shows nothing in a browser', async () => {
const { container } = render(<DesktopKeystoreSettings />);
await new Promise((resolve) => setTimeout(resolve, 5));
expect(container.textContent).toBe('');
});
});
@@ -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 (
<SettingsSection id="desktop-keystore" title={t("title")} description={t("description")}>
<SettingItem label={t("label")} description={t("label_description")}>
<Button variant="outline" onClick={() => void revoke()}>{t("revoke")}</Button>
</SettingItem>
</SettingsSection>
);
}
+2
View File
@@ -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
<LanguageSettings />
{!IS_LITE && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
<DesktopKeystoreSettings />
<SettingsSection id="tour" title={tTour('settings_section')}>
<SettingItem label={tTour('restart_title')} description={tTour('restart_desc')}>
@@ -0,0 +1,114 @@
/**
* 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<unknown>;
sign(id: string, digest: Uint8Array, scheme: unknown): Promise<Uint8Array>;
decrypt(id: string, data: Uint8Array, scheme: unknown): Promise<Uint8Array>;
agree(id: string, peer: Uint8Array): Promise<Uint8Array>;
}
describe('api.keystore in the sandbox', () => {
let hostWindow: { postMessage: ReturnType<typeof vi.fn> };
const realParent = Object.getOwnPropertyDescriptor(window, 'parent');
const g = globalThis as { __pluginApi?: { keystore: KeystoreFacade; host: { features: Record<string, boolean> } } };
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<string, boolean>): Promise<void> {
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('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<unknown>) => promise.then(() => settled.push(name), () => settled.push(`${name} rejected`));
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.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);
});
+344
View File
@@ -0,0 +1,344 @@
/**
* 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> = {}): 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<typeof fakeBridge> | 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<typeof fakeBridge>;
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<typeof fakeBridge>;
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<Uint8Array<ArrayBuffer>>((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 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<object>(() => {}));
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<object>(() => {}));
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();
});
});
+72
View File
@@ -111,6 +111,77 @@ export interface DesktopPluginsBridge {
install: (slug: string, version?: string) => Promise<DesktopPluginInstallResult>;
}
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;
/** 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. */
| { 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 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. The OS doesn't prompt. */
list: () => Promise<DesktopKeystoreCertificate[]>;
/** RSA gives the raw signature, ECDSA a DER ECDSA-Sig-Value. */
sign: (id: string, digest: Uint8Array, scheme: DesktopKeystoreSignScheme) => Promise<Uint8Array>;
/** Unwraps a CMS content key. */
decrypt: (id: string, data: Uint8Array, scheme: DesktopKeystoreDecryptScheme) => Promise<Uint8Array>;
/** The raw shared secret, big-endian. The peer's key is an EC SubjectPublicKeyInfo. */
agree: (id: string, peerPublicKey: Uint8Array) => Promise<Uint8Array>;
accessState: () => Promise<DesktopKeystoreAccessState>;
/** Takes back a stored yes and this session's grants. Only ever narrows. */
revokeAccess: () => Promise<void>;
}
export interface BulwarkDesktopBridge {
version: number;
platform: 'win32' | 'darwin' | 'linux';
@@ -123,6 +194,7 @@ export interface BulwarkDesktopBridge {
servers?: DesktopServersBridge;
auth?: DesktopAuthBridge;
plugins?: DesktopPluginsBridge;
keystore?: DesktopKeystoreBridge;
}
declare global {
+7
View File
@@ -456,6 +456,13 @@ export function checkPluginManifest(m: Record<string, unknown>, 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');
}
+17
View File
@@ -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<T>(pluginId: string, promise: T | Promise<T>, ms: number =
if (!(promise instanceof Promise)) return Promise.resolve(promise);
return new Promise<T>((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | undefined;
let deadline: ReturnType<typeof setTimeout> | 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();
+132
View File
@@ -0,0 +1,132 @@
// 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.
// 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,
// 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<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw invalid();
return value as Record<string, unknown>;
}
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<unknown> {
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<DesktopKeystoreCertificate[] | Uint8Array> => {
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. 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) {
throw codeOnly(error);
} finally {
release();
}
}
+6
View File
@@ -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<string>([
// 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<Record<string, Permission | null>> = {
// 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;
+2 -1
View File
@@ -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<HostFeaturesView> {
return assistantFeatures(this.plugin);
return { ...assistantFeatures(this.plugin), ...keystoreFeature(this.plugin) };
}
setHostFeatures(features: HostFeaturesView): void {
+23 -2
View File
@@ -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<string, number>();
/**
* 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);
}
/**
+2
View File
@@ -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),
};
}
+2
View File
@@ -11,6 +11,8 @@ import { ALL_PERMISSIONS, IMPLICIT_PERMISSIONS } from '../plugin-types';
*/
export const HIGH_RISK_PERMISSIONS: ReadonlySet<string> = 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',
+18
View File
@@ -506,6 +506,24 @@ 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. 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. 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<Uint8Array>,
/** 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<Uint8Array>,
/** 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<Uint8Array>,
},
/** 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<AppInfoView>,
+10
View File
@@ -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',
+12
View File
@@ -5879,6 +5879,10 @@
"title": "وصول كامل إلى هذا التطبيق",
"body": "تعمل بالصلاحيات نفسها التي يملكها تطبيق البريد. يمكنها قراءة كل بريدك ومفاتيحك المخزّنة والتصرف نيابةً عنك. لا تسمح بذلك إلا للإضافات التي تثق بها تمامًا."
},
"keystore": {
"title": "استخدام الشهادات الموجودة على هذا الكمبيوتر",
"body": "طلب توقيع الرسائل وفك تشفيرها باستخدام الشهادات الموجودة على هذا الكمبيوتر أو على بطاقة ذكية. يسألك التطبيق أولاً. لا يحصل المكوّن الإضافي على المفتاح الخاص أبدًا، ولكن طالما سمحت بذلك يمكنه التوقيع باسمك وقراءة ما يفك تشفيره."
},
"email_raw_send": {
"title": "إرسال رسائل أنشأتها بنفسها",
"body": "إرسال رسائل كاملة من حسابك، مثل الرسائل الموقّعة أو المشفّرة."
@@ -6529,5 +6533,13 @@
"server_needed": "أدخل عنوان الخادم أولًا.",
"server_declined": "لم تسمح بهذا الخادم، لذا لم يُرسل إليه أي شيء.",
"server_invalid": "عنوان الخادم هذا غير صالح."
},
"desktop_keystore": {
"title": "الشهادات الموجودة على هذا الكمبيوتر",
"description": "يمكن لهذا الخادم أن يطلب استخدام شهاداتك وبطاقاتك الذكية. سيُطلب إذنك مع ذلك قبل استخدام كل شهادة.",
"label": "وصول هذا الخادم",
"label_description": "اسحب إذنك. سيتعين على الخادم أن يطلبه مرة أخرى.",
"revoke": "سحب الإذن",
"revoked": "تم سحب الإذن"
}
}
+12
View File
@@ -5879,6 +5879,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": "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",
"body": "Enviar missatges complets, per exemple signats o xifrats, des del vostre compte."
@@ -6529,5 +6533,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"
}
}
+12
View File
@@ -5879,6 +5879,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": "Žá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",
"body": "Odesílá z vašeho účtu kompletní zprávy, například podepsané nebo šifrované."
@@ -6529,5 +6533,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"
}
}
+12
View File
@@ -5879,6 +5879,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": "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",
"body": "Sende færdige beskeder, f.eks. signerede eller krypterede, fra din konto."
@@ -6529,5 +6533,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"
}
}
+12
View File
@@ -5879,6 +5879,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": "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",
"body": "Vollständige Nachrichten, zum Beispiel signierte oder verschlüsselte, von Ihrem Konto senden."
@@ -6529,5 +6533,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"
}
}
+12
View File
@@ -5879,6 +5879,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": "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",
"body": "Send complete messages, for example signed or encrypted ones, from your account."
@@ -6529,5 +6533,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"
}
}
+12
View File
@@ -5879,6 +5879,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": "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",
"body": "Enviar mensajes completos, por ejemplo firmados o cifrados, desde tu cuenta."
@@ -6529,5 +6533,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"
}
}
+12
View File
@@ -5879,6 +5879,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": "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",
"body": "Saata sinu kontolt täielikke kirju, näiteks allkirjastatud või krüpteeritud."
@@ -6529,5 +6533,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"
}
}
+12
View File
@@ -5879,6 +5879,10 @@
"title": "دسترسی کامل به این برنامه",
"body": "با همان دسترسی‌های خود ایمیل تحت وب اجرا می‌شود. می‌تواند همه ایمیل‌ها و کلیدهای ذخیره‌شده شما را بخواند و از طرف شما عمل کند. فقط برای افزونه‌هایی که کاملاً به آن‌ها اعتماد دارید اجازه دهید."
},
"keystore": {
"title": "استفاده از گواهی‌های این رایانه",
"body": "درخواست امضا و رمزگشایی پیام‌ها با گواهی‌های این رایانه یا کارت هوشمند. برنامه ابتدا از شما می‌پرسد. افزونه هرگز کلید خصوصی را دریافت نمی‌کند، اما تا زمانی که اجازه دهید می‌تواند به نام شما امضا کند و آنچه را رمزگشایی می‌کند بخواند."
},
"email_raw_send": {
"title": "ارسال پیام‌هایی که خودش ساخته است",
"body": "ارسال پیام‌های کامل، مثلاً امضاشده یا رمزنگاری‌شده، از حساب شما."
@@ -6529,5 +6533,13 @@
"server_needed": "ابتدا نشانی سرور را وارد کنید.",
"server_declined": "شما این سرور را مجاز نکردید، پس چیزی به آن فرستاده نشد.",
"server_invalid": "این نشانی سرور معتبر نیست."
},
"desktop_keystore": {
"title": "گواهی‌های این رایانه",
"description": "این سرور می‌تواند برای استفاده از گواهی‌ها و کارت‌های هوشمند شما درخواست دهد. با این حال پیش از استفاده از هر گواهی از شما پرسیده می‌شود.",
"label": "دسترسی این سرور",
"label_description": "اجازهٔ خود را پس بگیرید. سرور باید دوباره درخواست کند.",
"revoke": "پس گرفتن",
"revoked": "اجازه پس گرفته شد"
}
}
+12
View File
@@ -5879,6 +5879,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": "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",
"body": "Envoyer des messages complets, par exemple signés ou chiffrés, depuis votre compte."
@@ -6529,5 +6533,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"
}
}
+12
View File
@@ -5880,6 +5880,10 @@
"title": "גישה מלאה לאפליקציה הזו",
"body": "פועל עם אותן הרשאות כמו ה-Webmail עצמו. הוא יכול לקרוא את כל הדואר והמפתחות השמורים שלכם ולפעול בשמכם. אפשרו זאת רק לתוספים שאתם סומכים עליהם לגמרי."
},
"keystore": {
"title": "שימוש בתעודות במחשב זה",
"body": "מבקש לחתום על הודעות ולפענח אותן באמצעות תעודות במחשב זה או בכרטיס חכם. האפליקציה שואלת אותך קודם. התוסף אף פעם לא מקבל את המפתח הפרטי, אבל כל עוד אתה מאפשר זאת הוא יכול לחתום בשמך ולקרוא את מה שהוא מפענח."
},
"email_raw_send": {
"title": "שליחת הודעות שהוא בנה בעצמו",
"body": "שליחת הודעות שלמות, למשל חתומות או מוצפנות, מהחשבון שלכם."
@@ -6530,5 +6534,13 @@
"server_needed": "קודם צריך להזין את כתובת השרת.",
"server_declined": "לא אישרת את השרת הזה, לכן לא נשלח אליו דבר.",
"server_invalid": "כתובת השרת הזו לא תקינה."
},
"desktop_keystore": {
"title": "תעודות במחשב זה",
"description": "שרת זה יכול לבקש להשתמש בתעודות ובכרטיסים החכמים שלך. עדיין תישאל לפני השימוש בכל תעודה.",
"label": "גישה לשרת זה",
"label_description": "בטל את ההרשאה שלך. השרת יצטרך לבקש שוב.",
"revoke": "ביטול",
"revoked": "ההרשאה בוטלה"
}
}
+12
View File
@@ -5879,6 +5879,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": "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",
"body": "Teljes üzeneteket küld a fiókodból, például aláírt vagy titkosított üzeneteket."
@@ -6529,5 +6533,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"
}
}
+12
View File
@@ -5879,6 +5879,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": "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é",
"body": "Inviare messaggi completi, ad esempio firmati o cifrati, dal tuo account."
@@ -6529,5 +6533,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"
}
}
+12
View File
@@ -5879,6 +5879,10 @@
"title": "このアプリへのフルアクセス",
"body": "ウェブメール本体と同じ権限で動作します。すべてのメールと保存された鍵を読み、あなたに代わって操作できます。完全に信頼できるプラグインにのみ許可してください。"
},
"keystore": {
"title": "このコンピューターの証明書を使用",
"body": "このコンピューターまたはスマートカードの証明書でメッセージの署名と復号を行うよう求めます。アプリが先に確認します。プラグインが秘密鍵を受け取ることはありませんが、許可している間はあなたの名前で署名し、復号した内容を読むことができます。"
},
"email_raw_send": {
"title": "自分で作成したメッセージを送信",
"body": "署名済みや暗号化済みのメッセージなど、完成したメッセージをあなたのアカウントから送信できます。"
@@ -6529,5 +6533,13 @@
"server_needed": "先にサーバーのアドレスを入力してください。",
"server_declined": "このサーバーを許可しなかったため、何も送信していません。",
"server_invalid": "このサーバーアドレスは正しくありません。"
},
"desktop_keystore": {
"title": "このコンピューターの証明書",
"description": "このサーバーは、証明書とスマートカードの使用を求めることができます。各証明書を使用する前には、引き続き確認されます。",
"label": "このサーバーのアクセス",
"label_description": "許可を取り消します。サーバーは再度確認する必要があります。",
"revoke": "取り消す",
"revoked": "許可を取り消しました"
}
}
+12
View File
@@ -5879,6 +5879,10 @@
"title": "이 앱에 대한 전체 접근 권한",
"body": "웹메일과 같은 권한으로 실행돼요. 모든 메일과 저장된 키를 읽고 사용자를 대신해 작업할 수 있어요. 완전히 신뢰하는 플러그인에만 허용하세요."
},
"keystore": {
"title": "이 컴퓨터의 인증서 사용",
"body": "이 컴퓨터나 스마트 카드의 인증서로 메시지 서명과 복호화를 요청합니다. 앱이 먼저 사용자에게 묻습니다. 플러그인은 개인 키를 받지 않지만, 허용하는 동안에는 사용자 이름으로 서명하고 복호화한 내용을 읽을 수 있습니다."
},
"email_raw_send": {
"title": "직접 만든 메시지 보내기",
"body": "서명되거나 암호화된 메시지 같은 완성된 메시지를 내 계정에서 보낼 수 있어요."
@@ -6529,5 +6533,13 @@
"server_needed": "먼저 서버 주소를 입력하세요.",
"server_declined": "이 서버를 허용하지 않아 아무것도 보내지 않았습니다.",
"server_invalid": "이 서버 주소는 올바르지 않습니다."
},
"desktop_keystore": {
"title": "이 컴퓨터의 인증서",
"description": "이 서버는 인증서와 스마트 카드 사용을 요청할 수 있습니다. 각 인증서를 사용하기 전에는 여전히 사용자에게 묻습니다.",
"label": "이 서버의 액세스",
"label_description": "허용을 취소합니다. 서버는 다시 요청해야 합니다.",
"revoke": "취소",
"revoked": "허용을 취소했습니다"
}
}
+12
View File
@@ -5879,6 +5879,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": "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",
"body": "Sūtīt no jūsu konta pilnīgas vēstules, piemēram, parakstītas vai šifrētas."
@@ -6529,5 +6533,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"
}
}
+12
View File
@@ -5879,6 +5879,10 @@
"title": "Аппад бүрэн хандах",
"body": "Webmail-тэй адил эрхээр ажиллана. Таны бүх имэйл болон хадгалсан түлхүүрүүдийг уншиж, таны нэрийн өмнөөс үйлдэл хийж чадна. Зөвхөн бүрэн итгэдэг залгаасдаа үүнийг зөвшөөрнө үү."
},
"keystore": {
"title": "Энэ компьютер дээрх гэрчилгээг ашиглах",
"body": "Энэ компьютер эсвэл ухаалаг картан дээрх гэрчилгээгээр мессежид гарын үсэг зурах, тайлахыг хүснэ. Апп эхлээд танаас асууна. Өргөтгөл хувийн түлхүүрийг хэзээ ч авахгүй, гэхдээ таныг зөвшөөрч байх хугацаанд таны нэрээр гарын үсэг зурж, тайлсан зүйлээ уншиж чадна."
},
"email_raw_send": {
"title": "Өөрөө бэлтгэсэн имэйл илгээх",
"body": "Таны бүртгэлээс бүрэн бэлэн имэйл, жишээ нь гарын үсэг зурсан эсвэл шифрлэсэн имэйл илгээх."
@@ -6529,5 +6533,13 @@
"server_needed": "Эхлээд серверийн хаягаа оруулна уу.",
"server_declined": "Та энэ серверийг зөвшөөрөөгүй тул юу ч илгээгээгүй.",
"server_invalid": "Энэ серверийн хаяг буруу байна."
},
"desktop_keystore": {
"title": "Энэ компьютер дээрх гэрчилгээ",
"description": "Энэ сервер таны гэрчилгээ, ухаалаг картыг ашиглахыг хүсэж болно. Гэрчилгээ бүрийг ашиглахын өмнө танаас асуусаар байх болно.",
"label": "Энэ серверийн хандалт",
"label_description": "Зөвшөөрлөө буцааж авна уу. Сервер дахин асуух шаардлагатай.",
"revoke": "Буцааж авах",
"revoked": "Зөвшөөрлийг буцааж авлаа"
}
}
+12
View File
@@ -5879,6 +5879,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": "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",
"body": "Sende ferdige meldinger, for eksempel signerte eller krypterte, fra kontoen din."
@@ -6529,5 +6533,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"
}
}
+12
View File
@@ -5879,6 +5879,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": "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",
"body": "Volledige berichten, bijvoorbeeld ondertekende of versleutelde, vanaf je account verzenden."
@@ -6529,5 +6533,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"
}
}
+12
View File
@@ -5879,6 +5879,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": "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ę",
"body": "Wysyła z Twojego konta kompletne wiadomości, na przykład podpisane lub zaszyfrowane."
@@ -6529,5 +6533,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"
}
}
+12
View File
@@ -5879,6 +5879,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": "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",
"body": "Enviar mensagens completas, por exemplo assinadas ou criptografadas, a partir da sua conta."
@@ -6529,5 +6533,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"
}
}
+12
View File
@@ -5879,6 +5879,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": "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",
"body": "Trimite mesaje complete, de exemplu semnate sau criptate, din contul dvs."
@@ -6529,5 +6533,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ă"
}
}
+12
View File
@@ -5879,6 +5879,10 @@
"title": "Полный доступ к приложению",
"body": "Работает с теми же правами, что и сам webmail. Может читать всю вашу почту и сохранённые ключи и действовать от вашего имени. Разрешайте это только плагинам, которым полностью доверяете."
},
"keystore": {
"title": "Использовать сертификаты на этом компьютере",
"body": "Запрашивает подпись и расшифровку сообщений сертификатами на этом компьютере или смарт-карте. Сначала приложение спросит вас. Плагин никогда не получает закрытый ключ, но пока вы разрешаете, он может подписывать от вашего имени и читать то, что расшифровывает."
},
"email_raw_send": {
"title": "Отправлять собственные письма",
"body": "Отправлять с вашего аккаунта готовые письма, например подписанные или зашифрованные."
@@ -6529,5 +6533,13 @@
"server_needed": "Сначала введите адрес сервера.",
"server_declined": "Вы не разрешили этот сервер, поэтому ему ничего не отправлено.",
"server_invalid": "Этот адрес сервера недействителен."
},
"desktop_keystore": {
"title": "Сертификаты на этом компьютере",
"description": "Этот сервер может запрашивать использование ваших сертификатов и смарт-карт. Перед использованием каждого сертификата вас всё равно спросят.",
"label": "Доступ для этого сервера",
"label_description": "Отзовите своё разрешение. Серверу придётся запросить его снова.",
"revoke": "Отозвать",
"revoked": "Разрешение отозвано"
}
}
+12
View File
@@ -5879,6 +5879,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": "Ž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",
"body": "Odosiela z vášho účtu kompletné správy, napríklad podpísané alebo šifrované."
@@ -6529,5 +6533,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é"
}
}
+12
View File
@@ -2794,6 +2794,10 @@
"title": "สิทธิ์การเข้าถึงแอปพลิเคชันอย่างเต็มรูปแบบ",
"body": "ทำงานด้วยสิทธิ์เดียวกันกับตัวเว็บเมลเอง สามารถอ่านอีเมลทั้งหมดและคีย์ลับที่จัดเก็บไว้ รวมถึงดำเนินการแทนคุณได้ อนุญาตเฉพาะปลั๊กอินที่คุณไว้วางใจอย่างสมบูรณ์เท่านั้น"
},
"keystore": {
"title": "ใช้ใบรับรองในคอมพิวเตอร์เครื่องนี้",
"body": "ขอลงนามและถอดรหัสข้อความด้วยใบรับรองในคอมพิวเตอร์เครื่องนี้หรือบนสมาร์ตการ์ด แอปจะถามคุณก่อน ปลั๊กอินไม่ได้รับคีย์ส่วนตัว แต่ตราบใดที่คุณอนุญาต ปลั๊กอินจะลงนามในนามของคุณและอ่านสิ่งที่ถอดรหัสได้"
},
"email_raw_send": {
"title": "ส่งข้อความที่สร้างขึ้นเอง",
"body": "ส่งข้อความฉบับสมบูรณ์ เช่น ข้อความที่มีการลงลายมือชื่อดิจิทัลหรือเข้ารหัส จากบัญชีของคุณ"
@@ -6537,5 +6541,13 @@
"server_needed": "ป้อนที่อยู่เซิร์ฟเวอร์ก่อน",
"server_declined": "คุณไม่ได้อนุญาตเซิร์ฟเวอร์นี้ จึงไม่มีการส่งข้อมูลใดไป",
"server_invalid": "ที่อยู่เซิร์ฟเวอร์นี้ไม่ถูกต้อง"
},
"desktop_keystore": {
"title": "ใบรับรองในคอมพิวเตอร์เครื่องนี้",
"description": "เซิร์ฟเวอร์นี้สามารถขอใช้ใบรับรองและสมาร์ตการ์ดของคุณได้ คุณจะยังถูกถามก่อนใช้ใบรับรองแต่ละใบ",
"label": "การเข้าถึงของเซิร์ฟเวอร์นี้",
"label_description": "ถอนการอนุญาตของคุณ เซิร์ฟเวอร์จะต้องขออีกครั้ง",
"revoke": "ถอน",
"revoked": "ถอนการอนุญาตแล้ว"
}
}
+12
View File
@@ -5879,6 +5879,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": "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",
"body": "Hesabınızdan imzalı veya şifreli iletiler gibi eksiksiz iletiler gönderme."
@@ -6529,5 +6533,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ı"
}
}
+12
View File
@@ -5879,6 +5879,10 @@
"title": "Повний доступ до програми",
"body": "Працює з тими самими правами, що й сам webmail. Може читати всю вашу пошту та збережені ключі й діяти від вашого імені. Дозволяйте це лише плагінам, яким повністю довіряєте."
},
"keystore": {
"title": "Використовувати сертифікати на цьому комп'ютері",
"body": "Просить підписувати та розшифровувати повідомлення сертифікатами на цьому комп'ютері або смарт-картці. Спершу застосунок запитає вас. Плагін ніколи не отримує закритий ключ, але поки ви дозволяєте, він може підписувати від вашого імені та читати те, що розшифровує."
},
"email_raw_send": {
"title": "Надсилати власні листи",
"body": "Надсилати з вашого облікового запису готові листи, наприклад підписані або зашифровані."
@@ -6529,5 +6533,13 @@
"server_needed": "Спочатку введіть адресу сервера.",
"server_declined": "Ви не дозволили цей сервер, тому йому нічого не надіслано.",
"server_invalid": "Ця адреса сервера недійсна."
},
"desktop_keystore": {
"title": "Сертифікати на цьому комп'ютері",
"description": "Цей сервер може просити використовувати ваші сертифікати та смарт-картки. Перед використанням кожного сертифіката вас однаково запитають.",
"label": "Доступ для цього сервера",
"label_description": "Відкличте свій дозвіл. Серверу доведеться запитати знову.",
"revoke": "Відкликати",
"revoked": "Дозвіл відкликано"
}
}
+12
View File
@@ -5891,6 +5891,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": "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",
"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."
@@ -6541,5 +6545,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"
}
}
+12
View File
@@ -5879,6 +5879,10 @@
"title": "完整存取此應用程式",
"body": "以與網頁郵件本身相同的權限執行。它可以讀取您所有的郵件和已儲存的金鑰,並代表您執行動作。請只對您完全信任的外掛程式允許。"
},
"keystore": {
"title": "使用這台電腦上的憑證",
"body": "要求使用這台電腦或智慧卡上的憑證為郵件簽署及解密。應用程式會先詢問你。外掛程式絕不會取得私密金鑰,但在你允許期間,它可以用你的名義簽署,並讀取它解密的內容。"
},
"email_raw_send": {
"title": "傳送它自行建立的郵件",
"body": "從您的帳號傳送完整的郵件,例如已簽署或已加密的郵件。"
@@ -6529,5 +6533,13 @@
"server_needed": "請先輸入伺服器位址。",
"server_declined": "你沒有允許此伺服器,因此沒有傳送任何內容給它。",
"server_invalid": "此伺服器位址無效。"
},
"desktop_keystore": {
"title": "這台電腦上的憑證",
"description": "此伺服器可以要求使用你的憑證和智慧卡。每次使用憑證前仍會詢問你。",
"label": "此伺服器的存取權",
"label_description": "收回你的許可。伺服器需要重新詢問。",
"revoke": "收回",
"revoked": "已收回許可"
}
}
+12
View File
@@ -5879,6 +5879,10 @@
"title": "完全访问此应用",
"body": "以与网页邮箱本身相同的权限运行。它可以读取您的所有邮件和已存储的密钥,并代表您执行操作。请只对您完全信任的插件允许。"
},
"keystore": {
"title": "使用此电脑上的证书",
"body": "请求使用此电脑或智能卡上的证书为邮件签名和解密。应用会先询问你。插件永远拿不到私钥,但在你允许期间,它可以以你的名义签名,并读取它解密的内容。"
},
"email_raw_send": {
"title": "发送它自行构建的邮件",
"body": "从您的账户发送完整的邮件,例如已签名或已加密的邮件。"
@@ -6529,5 +6533,13 @@
"server_needed": "请先输入服务器地址。",
"server_declined": "你没有允许此服务器,因此没有向它发送任何内容。",
"server_invalid": "此服务器地址无效。"
},
"desktop_keystore": {
"title": "此电脑上的证书",
"description": "此服务器可以请求使用你的证书和智能卡。每次使用证书前仍会询问你。",
"label": "此服务器的访问权限",
"label_description": "收回你的许可。服务器需要重新询问。",
"revoke": "收回",
"revoked": "已收回许可"
}
}