mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-06 00:15:32 +00:00
feat: merge the plugin keystore capability
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user