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:
Linus Rath
2026-11-04 03:23:45 +01:00
parent f0e874103d
commit 9b61b79d5c
10 changed files with 358 additions and 59 deletions
+10 -4
View File
@@ -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$/);
+102 -22
View File
@@ -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');
+127 -1
View File
@@ -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
View File
@@ -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>;
+23 -11
View File
@@ -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');
}
+14
View File
@@ -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, '_');
+8 -1
View File
@@ -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);
}
+13 -7
View File
@@ -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: {
+2
View File
@@ -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
View File
@@ -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: {} };
}),