mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
fix: sign only CMS signed attributes through api.keystore, and ask a user about a managed plugin's keystore
The shell now takes a message's signed attributes where it took a digest, so a page can't get a login or TLS digest signed. A quiet option lets a plugin answer from what's allowed, for anything the user didn't ask for. An admin's install no longer covers the certificates on someone's own computer: a managed plugin gets `keystore` when that person says yes, also when an update adds it, and it's forgotten once everyone has signed out. Changed test assertions: the fixture plugin needs a grant now, as managed alone is no longer enough, and the bridge gets an options argument.
This commit is contained in:
@@ -20,10 +20,10 @@ function post(source: unknown, data: unknown): void {
|
||||
}
|
||||
|
||||
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>;
|
||||
list(options?: { quiet?: boolean }): Promise<unknown>;
|
||||
sign(id: string, signedAttributes: Uint8Array, scheme: unknown): Promise<Uint8Array>;
|
||||
decrypt(id: string, data: Uint8Array, scheme: unknown, options?: { quiet?: boolean }): Promise<Uint8Array>;
|
||||
agree(id: string, peer: Uint8Array, options?: { quiet?: boolean }): Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
describe('api.keystore in the sandbox', () => {
|
||||
@@ -104,6 +104,12 @@ describe('api.keystore in the sandbox', () => {
|
||||
const signing = api.sign('the-id', digest, scheme);
|
||||
const sent = requests().at(-1)!;
|
||||
expect(sent.args).toEqual(['the-id', digest, scheme]);
|
||||
void api.list({ quiet: true }).catch(() => {});
|
||||
expect(requests().at(-1)!.args).toEqual([{ quiet: true }]);
|
||||
void api.decrypt('the-id', new Uint8Array(4), { scheme: 'rsa-pkcs1' }, { quiet: true }).catch(() => {});
|
||||
expect(requests().at(-1)!.args).toEqual(['the-id', new Uint8Array(4), { scheme: 'rsa-pkcs1' }, { quiet: true }]);
|
||||
void api.agree('the-id', new Uint8Array(4), { quiet: true }).catch(() => {});
|
||||
expect(requests().at(-1)!.args).toEqual(['the-id', new Uint8Array(4), { quiet: true }]);
|
||||
post(hostWindow, { type: 'api-response', id: sent.id, ok: false, error: 'keystore:cancelled' });
|
||||
await expect(signing).rejects.toThrow(/^keystore:cancelled$/);
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ 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';
|
||||
import { DEFAULT_POLICY_SNAPSHOT } from '../desktop/policy-schema';
|
||||
import { declaredPermissions, HIGH_RISK_PERMISSIONS } from '../plugin-sandbox/permission-text';
|
||||
import { pluginHasPermission } from '../plugin-sandbox/permissions';
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
|
||||
@@ -28,6 +30,7 @@ function plugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
|
||||
type: 'hook',
|
||||
tier: 'privileged',
|
||||
managed: true,
|
||||
grantedPermissions: ['keystore'],
|
||||
permissions: ['crypto:full', 'keystore'],
|
||||
entrypoint: 'index.js',
|
||||
enabled: true,
|
||||
@@ -37,7 +40,7 @@ function plugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
|
||||
};
|
||||
}
|
||||
|
||||
const DIGEST = new Uint8Array(32).fill(7);
|
||||
const ATTRIBUTES = new Uint8Array(200).fill(7);
|
||||
const SIGN = { scheme: 'rsa-pkcs1', hash: 'SHA-256' };
|
||||
|
||||
function fakeBridge() {
|
||||
@@ -66,7 +69,7 @@ describe('who may call it', () => {
|
||||
|
||||
const METHODS: Array<[string, unknown[]]> = [
|
||||
['keystore.list', []],
|
||||
['keystore.sign', ['id', DIGEST, SIGN]],
|
||||
['keystore.sign', ['id', ATTRIBUTES, SIGN]],
|
||||
['keystore.decrypt', ['id', new Uint8Array(256), { scheme: 'rsa-pkcs1' }]],
|
||||
['keystore.agree', ['id', new Uint8Array(91)]],
|
||||
];
|
||||
@@ -91,9 +94,17 @@ describe('who may call it', () => {
|
||||
await expect(dispatchApiCall(notGiven, 'keystore.list', [], { privileged: true })).rejects.toThrow(/lacks permission "keystore"/);
|
||||
});
|
||||
|
||||
it('is refused for a plugin an admin installed until this user says yes too', async () => {
|
||||
const adminsOnly = plugin({ managed: true, grantedPermissions: [] });
|
||||
await expect(dispatchApiCall(adminsOnly, 'keystore.list', [], { privileged: true })).rejects.toThrow(/lacks permission "keystore"/);
|
||||
expect(keystoreFeature(adminsOnly)).toEqual({});
|
||||
expect(pluginHasPermission(adminsOnly, 'crypto:full'), 'its other permissions are the admin\'s call').toBe(true);
|
||||
expect(pluginHasPermission(plugin({ managed: true }), 'keystore')).toBe(true);
|
||||
});
|
||||
|
||||
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.sign', ['id', ATTRIBUTES, SIGN], { privileged: true })).resolves.toEqual(new Uint8Array([1, 2, 3]));
|
||||
expect(bridge.sign).toHaveBeenCalledWith('id', ATTRIBUTES, 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]));
|
||||
@@ -122,17 +133,17 @@ describe('what gets through to the shell', () => {
|
||||
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' }]],
|
||||
['an id that is no string', [42, ATTRIBUTES, SIGN]],
|
||||
['an empty id', ['', ATTRIBUTES, SIGN]],
|
||||
['a long id', ['x'.repeat(65), ATTRIBUTES, SIGN]],
|
||||
['attributes that are no bytes', ['id', 'not bytes', SIGN]],
|
||||
['empty attributes', ['id', new Uint8Array(0), SIGN]],
|
||||
['attributes past what S/MIME could need', ['id', new Uint8Array(4097), SIGN]],
|
||||
['no scheme', ['id', ATTRIBUTES, null]],
|
||||
['a list as scheme', ['id', ATTRIBUTES, []]],
|
||||
['an unknown scheme', ['id', ATTRIBUTES, { scheme: 'raw', hash: 'SHA-256' }]],
|
||||
['an unknown hash', ['id', ATTRIBUTES, { scheme: 'rsa-pkcs1', hash: 'MD5' }]],
|
||||
['no hash', ['id', ATTRIBUTES, { scheme: 'rsa-pkcs1' }]],
|
||||
])('sign refuses %s', async (_name, args) => {
|
||||
await expect(call('keystore.sign', ...args)).rejects.toThrow('keystore:invalid');
|
||||
expect(bridge.sign).not.toHaveBeenCalled();
|
||||
@@ -148,9 +159,9 @@ describe('what gets through to the shell', () => {
|
||||
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);
|
||||
expect(bridge.decrypt).toHaveBeenCalledWith('id', data, { scheme: 'rsa-oaep', hash: 'SHA-1' }, {});
|
||||
await call('keystore.sign', 'id', ATTRIBUTES, { ...SIGN, extra: 'x' });
|
||||
expect(bridge.sign).toHaveBeenCalledWith('id', ATTRIBUTES, SIGN);
|
||||
});
|
||||
|
||||
it('agree refuses an oversized peer key', async () => {
|
||||
@@ -160,12 +171,81 @@ describe('what gets through to the shell', () => {
|
||||
|
||||
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}$`));
|
||||
await expect(call('keystore.sign', 'id', ATTRIBUTES, 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$/);
|
||||
await expect(call('keystore.sign', 'id', ATTRIBUTES, SIGN)).rejects.toThrow(/^keystore:failed$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a call that must not ask', () => {
|
||||
const call = (method: string, ...args: unknown[]) => dispatchApiCall(plugin(), method, args, { privileged: true });
|
||||
let bridge: ReturnType<typeof fakeBridge>;
|
||||
beforeEach(() => {
|
||||
bridge = fakeBridge();
|
||||
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, keystore: bridge };
|
||||
});
|
||||
afterEach(() => { delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; });
|
||||
|
||||
it('passes quiet to the shell, and only quiet', async () => {
|
||||
await call('keystore.list', { quiet: true, other: 1 });
|
||||
expect(bridge.list).toHaveBeenCalledWith({ quiet: true });
|
||||
await call('keystore.decrypt', 'id', new Uint8Array(256), { scheme: 'rsa-pkcs1' }, { quiet: true });
|
||||
expect(bridge.decrypt).toHaveBeenCalledWith('id', new Uint8Array(256), { scheme: 'rsa-pkcs1' }, { quiet: true });
|
||||
await call('keystore.agree', 'id', new Uint8Array(91), { quiet: true });
|
||||
expect(bridge.agree).toHaveBeenCalledWith('id', new Uint8Array(91), { quiet: true });
|
||||
});
|
||||
|
||||
it('a call without it, or with something else, can ask', async () => {
|
||||
await call('keystore.list');
|
||||
expect(bridge.list).toHaveBeenLastCalledWith({});
|
||||
await call('keystore.list', { quiet: 'yes' });
|
||||
expect(bridge.list).toHaveBeenLastCalledWith({});
|
||||
});
|
||||
|
||||
it('the shell\'s answer comes through as its code', async () => {
|
||||
bridge.list.mockRejectedValueOnce(new Error('keystore:ask-first'));
|
||||
await expect(call('keystore.list', { quiet: true })).rejects.toThrow(/^keystore:ask-first$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('policy', () => {
|
||||
const withPolicy = (values: Record<string, { value: unknown }>) => {
|
||||
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = {
|
||||
version: 1,
|
||||
keystore: fakeBridge(),
|
||||
policy: { get: () => ({ ...DEFAULT_POLICY_SNAPSHOT, ...values }) },
|
||||
};
|
||||
};
|
||||
afterEach(() => { delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; });
|
||||
|
||||
it.each([
|
||||
['KeystoreEnabled off', { KeystoreEnabled: { value: false } }],
|
||||
['PluginsMode off', { PluginsMode: { value: 'off' } }],
|
||||
])('%s takes the flag away and refuses the call', async (_name, values) => {
|
||||
withPolicy(values);
|
||||
expect(keystoreFeature(plugin())).toEqual({});
|
||||
await expect(dispatchApiCall(plugin(), 'keystore.list', [], { privileged: true })).rejects.toThrow('keystore:unsupported');
|
||||
});
|
||||
|
||||
it('leaves it alone otherwise', () => {
|
||||
withPolicy({});
|
||||
expect(keystoreFeature(plugin())).toEqual({ keystore: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('what a manifest declares', () => {
|
||||
it('counts optional permissions, as the install does', () => {
|
||||
expect(declaredPermissions({ tier: 'privileged', permissions: ['crypto:full', 'email:read'], optionalPermissions: ['keystore', 'email:read'] }))
|
||||
.toEqual(['crypto:full', 'email:read', 'keystore']);
|
||||
});
|
||||
|
||||
it('leaves out what an untrusted plugin can not get, what this version does not know, and junk', () => {
|
||||
expect(declaredPermissions({ permissions: ['email:read'], optionalPermissions: ['keystore', 'from:the-future', 7] })).toEqual(['email:read']);
|
||||
expect(declaredPermissions(null)).toEqual([]);
|
||||
expect(declaredPermissions({ permissions: 'email:read' })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -285,7 +365,7 @@ describe('a keystore call and the hook timer', () => {
|
||||
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');
|
||||
await expect(dispatchApiCall(plugin(), 'keystore.sign', ['id', ATTRIBUTES, 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');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { InstalledPlugin } from '@/lib/plugin-types';
|
||||
|
||||
// We test the raw store by directly invoking Zustand
|
||||
@@ -64,6 +64,7 @@ import { usePluginStore, PLUGIN_PERSIST_DELAY_MS } from '@/stores/plugin-store';
|
||||
import { pluginStorage } from '@/lib/plugin-storage';
|
||||
import { deactivatePlugin, forgetSlotOffers } from '@/lib/plugin-loader';
|
||||
import { purgeSignedOutData } from '@/lib/sign-out-cleanup';
|
||||
import { head as consentHead, resolveHead as resolveConsent, subscribe as subscribeConsent } from '@/lib/plugin-sandbox/consent';
|
||||
|
||||
function resetStore() {
|
||||
usePluginStore.setState({
|
||||
@@ -561,3 +562,128 @@ describe('saving the plugin list', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('a plugin an admin installed, and the certificates on this computer', () => {
|
||||
const serverSmime = {
|
||||
id: 'smime', name: 'S/MIME', version: '1.5.0', author: 'a', description: '', type: 'hook', tier: 'privileged',
|
||||
permissions: ['crypto:full', 'keystore'], entrypoint: 'index.js', forceEnabled: true, bundleHash: 'h',
|
||||
};
|
||||
const local = (overrides: Partial<InstalledPlugin> = {}) => mockPlugin({
|
||||
id: 'smime', name: 'S/MIME', version: '1.5.0', tier: 'privileged', managed: true, enabled: true, status: 'enabled', adminApproved: true, bundleHash: 'h',
|
||||
permissions: ['crypto:full', 'keystore'], ...overrides,
|
||||
});
|
||||
const answerConsent = (answer: boolean) => {
|
||||
const seen: string[][] = [];
|
||||
const stop = subscribeConsent(() => {
|
||||
const request = consentHead();
|
||||
if (!request) return;
|
||||
seen.push(request.permissions);
|
||||
queueMicrotask(() => resolveConsent(answer));
|
||||
});
|
||||
return { seen, stop };
|
||||
};
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
resetStore();
|
||||
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, keystore: {} };
|
||||
vi.mocked(pluginStorage.codeIds).mockResolvedValue(['smime']);
|
||||
mocks.apiFetch.mockImplementation(async (url: string) => (String(url).startsWith('/api/plugins')
|
||||
? new Response(JSON.stringify({ plugins: [serverSmime], themes: [] }))
|
||||
: new Response(JSON.stringify({}))));
|
||||
});
|
||||
afterEach(() => { delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; });
|
||||
|
||||
it('asks this person, for that alone, and keeps a yes', async () => {
|
||||
usePluginStore.setState({ plugins: [local()] });
|
||||
const { seen, stop } = answerConsent(true);
|
||||
await usePluginStore.getState().initializePlugins();
|
||||
await settle();
|
||||
stop();
|
||||
expect(seen).toEqual([['keystore']]);
|
||||
expect(usePluginStore.getState().plugins[0].grantedPermissions).toEqual(['keystore']);
|
||||
});
|
||||
|
||||
it('asks when an update adds it to a plugin that had none', async () => {
|
||||
usePluginStore.setState({ plugins: [local({ permissions: ['crypto:full'], version: '1.4.0' })] });
|
||||
vi.mocked(mocks.downloadManagedBundle).mockResolvedValue('code');
|
||||
const { seen, stop } = answerConsent(true);
|
||||
await usePluginStore.getState().initializePlugins();
|
||||
await settle();
|
||||
stop();
|
||||
expect(seen).toEqual([['keystore']]);
|
||||
});
|
||||
|
||||
it('runs it either way, and a no is not asked again', async () => {
|
||||
const { loadPlugin } = await import('@/lib/plugin-loader');
|
||||
vi.mocked(loadPlugin).mockClear();
|
||||
usePluginStore.setState({ plugins: [local()] });
|
||||
const first = answerConsent(false);
|
||||
await usePluginStore.getState().initializePlugins();
|
||||
await settle();
|
||||
first.stop();
|
||||
expect(first.seen).toHaveLength(1);
|
||||
expect(loadPlugin).toHaveBeenCalledTimes(1);
|
||||
expect(usePluginStore.getState().plugins[0].grantedPermissions ?? []).not.toContain('keystore');
|
||||
usePluginStore.setState({ initialized: false });
|
||||
const second = answerConsent(true);
|
||||
await usePluginStore.getState().initializePlugins();
|
||||
await settle();
|
||||
second.stop();
|
||||
expect(second.seen).toEqual([]);
|
||||
});
|
||||
|
||||
it('starts the plugin again after a yes, so it sees what it was given', async () => {
|
||||
const { loadPlugin } = await import('@/lib/plugin-loader');
|
||||
vi.mocked(loadPlugin).mockClear();
|
||||
vi.mocked(deactivatePlugin).mockClear();
|
||||
usePluginStore.setState({ plugins: [local()] });
|
||||
const { stop } = answerConsent(true);
|
||||
await usePluginStore.getState().initializePlugins();
|
||||
await settle();
|
||||
stop();
|
||||
expect(deactivatePlugin).toHaveBeenCalledWith('smime');
|
||||
expect(vi.mocked(loadPlugin).mock.lastCall?.[0]).toMatchObject({ grantedPermissions: ['keystore'] });
|
||||
});
|
||||
|
||||
it('asks nothing in a browser, where there is no such thing', async () => {
|
||||
delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop;
|
||||
usePluginStore.setState({ plugins: [local()] });
|
||||
const { seen, stop } = answerConsent(true);
|
||||
await usePluginStore.getState().initializePlugins();
|
||||
await settle();
|
||||
stop();
|
||||
expect(seen).toEqual([]);
|
||||
});
|
||||
|
||||
it('asks nothing for a plugin that does not want it', async () => {
|
||||
mocks.apiFetch.mockImplementation(async (url: string) => (String(url).startsWith('/api/plugins')
|
||||
? new Response(JSON.stringify({ plugins: [{ ...serverSmime, permissions: ['crypto:full'] }], themes: [] }))
|
||||
: new Response(JSON.stringify({}))));
|
||||
usePluginStore.setState({ plugins: [local({ permissions: ['crypto:full'] })] });
|
||||
const { seen, stop } = answerConsent(true);
|
||||
await usePluginStore.getState().initializePlugins();
|
||||
await settle();
|
||||
stop();
|
||||
expect(seen).toEqual([]);
|
||||
});
|
||||
|
||||
it('asks when someone turns it on themselves, and its other permissions stay the admin\'s call', async () => {
|
||||
usePluginStore.setState({ plugins: [local({ enabled: false, status: 'installed', forceEnabled: false })] });
|
||||
const { seen, stop } = answerConsent(true);
|
||||
await usePluginStore.getState().enablePlugin('smime');
|
||||
stop();
|
||||
expect(seen).toEqual([['keystore']]);
|
||||
expect(usePluginStore.getState().plugins[0]).toMatchObject({ enabled: true, grantedPermissions: ['keystore'] });
|
||||
});
|
||||
|
||||
it('forgets the answer once everyone has signed out: the next person gets asked', async () => {
|
||||
usePluginStore.setState({ plugins: [local({ grantedPermissions: ['keystore'], declinedPermissions: ['keystore'] })] });
|
||||
await purgeSignedOutData();
|
||||
const [plugin] = usePluginStore.getState().plugins;
|
||||
expect(plugin.grantedPermissions ?? []).toEqual([]);
|
||||
expect(plugin.declinedPermissions ?? []).toEqual([]);
|
||||
expect(plugin.managed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+19
-10
@@ -301,23 +301,32 @@ export type DesktopKeystoreDecryptScheme =
|
||||
/** MGF1 on the same hash. */
|
||||
| { scheme: 'rsa-oaep'; hash: DesktopKeystoreHash; label?: Uint8Array };
|
||||
|
||||
/** `quiet`: answer from what's already allowed, with keystore:ask-first in place of a dialog. */
|
||||
export interface DesktopKeystoreUseOptions {
|
||||
quiet?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* loaded, the OS has one and policy allows it. Failures reject with a bare code as the
|
||||
* message: keystore:cancelled, ask-first, 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>;
|
||||
list: (options?: DesktopKeystoreUseOptions) => Promise<DesktopKeystoreCertificate[]>;
|
||||
/**
|
||||
* Signs a CMS message's signed attributes, given as their DER (a SET): content type,
|
||||
* message digest and signing time at least. The shell checks them and hashes them itself,
|
||||
* and asks at every call. RSA gives the raw signature, ECDSA a DER ECDSA-Sig-Value.
|
||||
*/
|
||||
sign: (id: string, signedAttributes: Uint8Array, scheme: DesktopKeystoreSignScheme) => Promise<Uint8Array>;
|
||||
/** Unwraps a CMS content key. */
|
||||
decrypt: (id: string, data: Uint8Array, scheme: DesktopKeystoreDecryptScheme) => Promise<Uint8Array>;
|
||||
decrypt: (id: string, data: Uint8Array, scheme: DesktopKeystoreDecryptScheme, options?: DesktopKeystoreUseOptions) => Promise<Uint8Array>;
|
||||
/** The raw shared secret, big-endian. The peer's key is an EC SubjectPublicKeyInfo. */
|
||||
agree: (id: string, peerPublicKey: Uint8Array) => Promise<Uint8Array>;
|
||||
agree: (id: string, peerPublicKey: Uint8Array, options?: DesktopKeystoreUseOptions) => Promise<Uint8Array>;
|
||||
accessState: () => Promise<DesktopKeystoreAccessState>;
|
||||
/** Takes back a stored yes and this session's grants. Only ever narrows. */
|
||||
revokeAccess: () => Promise<void>;
|
||||
|
||||
@@ -8,17 +8,19 @@
|
||||
// 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.
|
||||
// keystore:cancelled, keystore:ask-first, 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 type { DesktopKeystoreBridge, DesktopKeystoreCertificate, DesktopKeystoreUseOptions } from '../desktop/types';
|
||||
import { keystoreOffByDesktopPolicy } from '../desktop/policy';
|
||||
import { holdForUser } from './host-dialog';
|
||||
import { pluginHasPermission } from './permissions';
|
||||
import { resolvePluginTier } from './tier';
|
||||
|
||||
const MAX_ID = 64;
|
||||
const MAX_DIGEST = 64;
|
||||
// The DER of a CMS message's signed attributes. The shell checks what they are.
|
||||
const MAX_SIGNED_ATTRIBUTES = 4096;
|
||||
// A 16384-bit modulus, the most anything issues.
|
||||
const MAX_CIPHERTEXT = 2048;
|
||||
const MAX_LABEL = 256;
|
||||
@@ -38,13 +40,18 @@ export const KEYSTORE_PERMS = {
|
||||
export const KEYSTORE_METHODS: readonly string[] = Object.keys(KEYSTORE_PERMS);
|
||||
|
||||
function desktopKeystore(): DesktopKeystoreBridge | undefined {
|
||||
return typeof window === 'undefined' ? undefined : window.bulwarkDesktop?.keystore;
|
||||
if (typeof window === 'undefined' || keystoreOffByDesktopPolicy()) return undefined;
|
||||
return window.bulwarkDesktop?.keystore;
|
||||
}
|
||||
|
||||
/** Whether this plugin could get at it at all, permission aside: the desktop app, policy, the tier. */
|
||||
export function keystoreCouldReach(plugin: InstalledPlugin): boolean {
|
||||
return !!desktopKeystore() && resolvePluginTier(plugin).tier === 'privileged';
|
||||
}
|
||||
|
||||
/** 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 {};
|
||||
if (!plugin || !keystoreCouldReach(plugin) || !pluginHasPermission(plugin, 'keystore')) return {};
|
||||
return { keystore: true };
|
||||
}
|
||||
|
||||
@@ -89,6 +96,11 @@ function checkedDecryptScheme(value: unknown): { scheme: string; hash?: string;
|
||||
return { scheme: scheme.scheme, hash: readHash(scheme.hash), ...(label ? { label } : {}) };
|
||||
}
|
||||
|
||||
/** Only `quiet`, and only when it's true: anything else may ask. */
|
||||
function readOptions(value: unknown): DesktopKeystoreUseOptions {
|
||||
return (value as { quiet?: unknown } | null | undefined)?.quiet === true ? { quiet: true } : {};
|
||||
}
|
||||
|
||||
/** 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];
|
||||
@@ -103,13 +115,13 @@ export async function doKeystoreCall(plugin: InstalledPlugin, method: string, ar
|
||||
const run = (): Promise<DesktopKeystoreCertificate[] | Uint8Array> => {
|
||||
switch (method) {
|
||||
case 'keystore.list':
|
||||
return keystore.list();
|
||||
return keystore.list(readOptions(args[0]));
|
||||
case 'keystore.sign':
|
||||
return keystore.sign(readId(args[0]), readBytes(args[1], MAX_DIGEST), checkedSignScheme(args[2]) as never);
|
||||
return keystore.sign(readId(args[0]), readBytes(args[1], MAX_SIGNED_ATTRIBUTES), checkedSignScheme(args[2]) as never);
|
||||
case 'keystore.decrypt':
|
||||
return keystore.decrypt(readId(args[0]), readBytes(args[1], MAX_CIPHERTEXT), checkedDecryptScheme(args[2]) as never);
|
||||
return keystore.decrypt(readId(args[0]), readBytes(args[1], MAX_CIPHERTEXT), checkedDecryptScheme(args[2]) as never, readOptions(args[3]));
|
||||
case 'keystore.agree':
|
||||
return keystore.agree(readId(args[0]), readBytes(args[1], MAX_PEER_KEY));
|
||||
return keystore.agree(readId(args[0]), readBytes(args[1], MAX_PEER_KEY), readOptions(args[2]));
|
||||
default:
|
||||
throw new Error('keystore:unsupported');
|
||||
}
|
||||
|
||||
@@ -40,6 +40,20 @@ export const HIGH_RISK_PERMISSIONS: ReadonlySet<string> = new Set([
|
||||
'settings:change',
|
||||
]);
|
||||
|
||||
/**
|
||||
* What a manifest asks for, as an install grants it (checkPluginManifest): optional
|
||||
* permissions count, minus what this version doesn't offer and what the tier can't have.
|
||||
* Everything that shows permissions from a manifest goes through this.
|
||||
*/
|
||||
export function declaredPermissions(manifest: unknown): string[] {
|
||||
const m = (manifest ?? {}) as { permissions?: unknown; optionalPermissions?: unknown; tier?: unknown };
|
||||
const strings = (value: unknown) => (Array.isArray(value) ? value.filter((p): p is string => typeof p === 'string') : []);
|
||||
const known = new Set<string>(ALL_PERMISSIONS);
|
||||
return [...new Set([...strings(m.permissions), ...strings(m.optionalPermissions)])]
|
||||
.filter((p) => known.has(p))
|
||||
.filter((p) => p !== 'keystore' || m.tier === 'privileged');
|
||||
}
|
||||
|
||||
/** Locale key segment for a permission: `email:read` → `email_read`. */
|
||||
export function permissionKey(perm: string): string {
|
||||
return perm.replace(/[:-]/g, '_');
|
||||
|
||||
@@ -3,6 +3,13 @@ import { IMPLICIT_PERMISSIONS } from '../plugin-types';
|
||||
import { hookSpec } from '../plugin-hook-catalog';
|
||||
import { slotSpec } from '../plugin-slot-catalog';
|
||||
|
||||
/**
|
||||
* What an admin's install doesn't settle. These reach things on the user's own computer that
|
||||
* the admin has no say over, so the user is asked even for a plugin the admin put there, and
|
||||
* also when an update adds one to it.
|
||||
*/
|
||||
export const ALWAYS_ASK_USER: ReadonlySet<string> = new Set(['keystore']);
|
||||
|
||||
/**
|
||||
* Whether `plugin` may use `perm`: the manifest must declare it, and an
|
||||
* admin must have installed the plugin (managed) or the user must have
|
||||
@@ -14,7 +21,7 @@ export function pluginHasPermission(plugin: InstalledPlugin, perm: Permission):
|
||||
// Defense-in-depth: even if the manifest declares a permission, the host
|
||||
// refuses it unless an admin has marked the plugin as managed, or the user
|
||||
// has explicitly granted it via the consent dialog.
|
||||
if (plugin.managed) return true;
|
||||
if (plugin.managed && !ALWAYS_ASK_USER.has(perm)) return true;
|
||||
return (plugin.grantedPermissions ?? []).includes(perm);
|
||||
}
|
||||
|
||||
|
||||
@@ -512,18 +512,24 @@ function buildCoreApi(manifest: PluginManifest) {
|
||||
* 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.
|
||||
* gives up by itself. Errors are bare codes: keystore:cancelled, ask-first, not-found,
|
||||
* unsupported, locked, invalid, failed. `{ quiet: true }` never opens a dialog: it
|
||||
* answers from what's already allowed, or with ask-first. Use it for anything the user
|
||||
* didn't just ask for.
|
||||
*/
|
||||
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>,
|
||||
list: (options?: { quiet?: boolean }) => callApi('keystore.list', [options]),
|
||||
/**
|
||||
* Signs a CMS message's signed attributes, given as their DER (a SET): content type,
|
||||
* message digest and signing time at least. The shell hashes them, and asks every time.
|
||||
* scheme: { scheme: 'rsa-pkcs1' | 'rsa-pss' | 'ecdsa', hash }. RSA gives the raw signature, ECDSA DER.
|
||||
*/
|
||||
sign: (id: string, signedAttributes: Uint8Array, scheme: unknown) => callApi('keystore.sign', [id, signedAttributes, 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>,
|
||||
decrypt: (id: string, data: Uint8Array, scheme: unknown, options?: { quiet?: boolean }) => callApi('keystore.decrypt', [id, data, scheme, options], 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>,
|
||||
agree: (id: string, peerPublicKey: Uint8Array, options?: { quiet?: boolean }) => callApi('keystore.agree', [id, peerPublicKey, options], 0) as Promise<Uint8Array>,
|
||||
},
|
||||
/** The app the plugin runs in: product, the administrator's name for it, version, edition. No permission needed. */
|
||||
app: {
|
||||
|
||||
@@ -283,6 +283,8 @@ export interface InstalledPlugin {
|
||||
* skip the consent prompt (admin pre-approval).
|
||||
*/
|
||||
grantedPermissions?: string[];
|
||||
/** Asked for a plugin an admin installed (ALWAYS_ASK_USER), and the user said no: not asked again. */
|
||||
declinedPermissions?: string[];
|
||||
/**
|
||||
* For plugins the user installed: the validated manifest, kept so the
|
||||
* package can be rebuilt for an approval request, and the hash an admin
|
||||
|
||||
+40
-3
@@ -14,6 +14,7 @@ import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { getEffectiveLocale } from '@/i18n/detect-locale';
|
||||
import { removeAllPluginHooks } from '@/lib/plugin-hooks';
|
||||
import { requestConsent } from '@/lib/plugin-sandbox/consent';
|
||||
import { keystoreCouldReach } from '@/lib/plugin-sandbox/host-api-keystore';
|
||||
import { packageHash, type ValidatedPluginManifest } from '@/lib/extension-package';
|
||||
import JSZip from 'jszip';
|
||||
import { sha256Hex } from '@/lib/plugin-sandbox/bundle-integrity';
|
||||
@@ -249,7 +250,9 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
|
||||
// Per-user consent gate: prompt for any permission the user has not
|
||||
// explicitly approved yet. Managed plugins (admin-pushed) skip this -
|
||||
// the admin has already approved them at install time.
|
||||
// the admin has already approved them at install time - but for what
|
||||
// an admin can't approve for someone else (askManagedKeystore).
|
||||
await askManagedKeystore(plugin, set);
|
||||
const implicit = new Set<string>(IMPLICIT_PERMISSIONS);
|
||||
const granted = new Set<string>(plugin.grantedPermissions ?? []);
|
||||
const missing = (plugin.permissions ?? [])
|
||||
@@ -401,6 +404,17 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
await Promise.allSettled(enabledPlugins.filter((_, i) => allowed[i]).map(plugin => loadPlugin(plugin)));
|
||||
|
||||
set({ initialized: true });
|
||||
|
||||
// After the start, which doesn't wait for anyone to answer. A plugin runs without
|
||||
// the keystore until then, and starts again to see what it was given.
|
||||
for (const plugin of get().plugins.filter(p => p.managed && p.enabled && p.status !== 'error')) {
|
||||
void askManagedKeystore(plugin, set).then((granted) => {
|
||||
const latest = get().plugins.find(p => p.id === plugin.id);
|
||||
if (!granted || !latest?.enabled) return;
|
||||
deactivatePlugin(plugin.id);
|
||||
return loadPlugin(latest);
|
||||
}).catch(() => {});
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
@@ -461,6 +475,25 @@ function removePluginData(id: string): void {
|
||||
}
|
||||
|
||||
/** Give the loader its way back into the store. */
|
||||
/**
|
||||
* An admin's install doesn't cover the certificates on this person's computer, so they're
|
||||
* asked for that alone, also when an update adds it. Runs with or without it, as with any
|
||||
* optional permission. Says whether this added a grant.
|
||||
*/
|
||||
async function askManagedKeystore(
|
||||
plugin: InstalledPlugin,
|
||||
set: (partial: Partial<PluginStoreState> | ((state: PluginStoreState) => Partial<PluginStoreState>)) => void,
|
||||
): Promise<boolean> {
|
||||
if (!plugin.managed || !plugin.permissions?.includes('keystore') || !keystoreCouldReach(plugin)) return false;
|
||||
if (plugin.grantedPermissions?.includes('keystore') || plugin.declinedPermissions?.includes('keystore')) return false;
|
||||
const accepted = await requestConsent(plugin.id, plugin.name, ['keystore' as Permission]);
|
||||
const field = accepted ? 'grantedPermissions' : 'declinedPermissions';
|
||||
set(state => ({
|
||||
plugins: state.plugins.map(p => (p.id === plugin.id ? { ...p, [field]: [...new Set([...(p[field] ?? []), 'keystore'])] } : p)),
|
||||
}));
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function wireLoader(
|
||||
get: () => PluginStoreState,
|
||||
set: (partial: Partial<PluginStoreState> | ((state: PluginStoreState) => Partial<PluginStoreState>)) => void,
|
||||
@@ -870,7 +903,8 @@ usePluginStore.subscribe((state, prev) => {
|
||||
// A plugin the user installed runs on that user's consent and settings. Once
|
||||
// everyone has signed out, the next person at this browser must be asked
|
||||
// again: the code stays, the grants and settings go, and it is left off.
|
||||
// The admin's plugins are the admin's choice and stay as they are.
|
||||
// The admin's plugins are the admin's choice and stay as they are, but for
|
||||
// what that person answered about their own certificates.
|
||||
onFullSignOut(() => {
|
||||
const { plugins } = usePluginStore.getState();
|
||||
for (const plugin of plugins) {
|
||||
@@ -878,7 +912,10 @@ onFullSignOut(() => {
|
||||
}
|
||||
usePluginStore.setState({
|
||||
plugins: plugins.map((plugin) => {
|
||||
if (plugin.managed) return plugin;
|
||||
if (plugin.managed) {
|
||||
const { grantedPermissions: _kept, declinedPermissions: _declined, ...kept } = plugin;
|
||||
return kept;
|
||||
}
|
||||
const { grantedPermissions: _granted, ...rest } = plugin;
|
||||
return { ...rest, enabled: false, status: 'disabled' as PluginStatus, error: undefined, settings: {} };
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user