From 9b61b79d5c0a2978a5fa63e479a1ca03393a040d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:23:45 +0100 Subject: [PATCH] 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. --- lib/__tests__/plugin-keystore-runtime.test.ts | 14 +- lib/__tests__/plugin-keystore.test.ts | 124 ++++++++++++++--- lib/__tests__/plugin-store.test.ts | 128 +++++++++++++++++- lib/desktop/types.ts | 29 ++-- lib/plugin-sandbox/host-api-keystore.ts | 34 +++-- lib/plugin-sandbox/permission-text.ts | 14 ++ lib/plugin-sandbox/permissions.ts | 9 +- lib/plugin-sandbox/runtime.tsx | 20 ++- lib/plugin-types.ts | 2 + stores/plugin-store.ts | 43 +++++- 10 files changed, 358 insertions(+), 59 deletions(-) diff --git a/lib/__tests__/plugin-keystore-runtime.test.ts b/lib/__tests__/plugin-keystore-runtime.test.ts index ab1d59dec..5aeced3dd 100644 --- a/lib/__tests__/plugin-keystore-runtime.test.ts +++ b/lib/__tests__/plugin-keystore-runtime.test.ts @@ -20,10 +20,10 @@ function post(source: unknown, data: unknown): void { } interface KeystoreFacade { - list(): Promise; - sign(id: string, digest: Uint8Array, scheme: unknown): Promise; - decrypt(id: string, data: Uint8Array, scheme: unknown): Promise; - agree(id: string, peer: Uint8Array): Promise; + list(options?: { quiet?: boolean }): Promise; + sign(id: string, signedAttributes: Uint8Array, scheme: unknown): Promise; + decrypt(id: string, data: Uint8Array, scheme: unknown, options?: { quiet?: boolean }): Promise; + agree(id: string, peer: Uint8Array, options?: { quiet?: boolean }): Promise; } 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$/); diff --git a/lib/__tests__/plugin-keystore.test.ts b/lib/__tests__/plugin-keystore.test.ts index 6820a7858..98128d935 100644 --- a/lib/__tests__/plugin-keystore.test.ts +++ b/lib/__tests__/plugin-keystore.test.ts @@ -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 { 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 { }; } -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; + 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) => { + (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'); diff --git a/lib/__tests__/plugin-store.test.ts b/lib/__tests__/plugin-store.test.ts index 3d208615d..f3429ce96 100644 --- a/lib/__tests__/plugin-store.test.ts +++ b/lib/__tests__/plugin-store.test.ts @@ -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 = {}) => 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); + }); +}); diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 2e80e6479..7a2a550a9 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -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; - /** RSA gives the raw signature, ECDSA a DER ECDSA-Sig-Value. */ - sign: (id: string, digest: Uint8Array, scheme: DesktopKeystoreSignScheme) => Promise; + list: (options?: DesktopKeystoreUseOptions) => Promise; + /** + * 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; /** Unwraps a CMS content key. */ - decrypt: (id: string, data: Uint8Array, scheme: DesktopKeystoreDecryptScheme) => Promise; + decrypt: (id: string, data: Uint8Array, scheme: DesktopKeystoreDecryptScheme, options?: DesktopKeystoreUseOptions) => Promise; /** The raw shared secret, big-endian. The peer's key is an EC SubjectPublicKeyInfo. */ - agree: (id: string, peerPublicKey: Uint8Array) => Promise; + agree: (id: string, peerPublicKey: Uint8Array, options?: DesktopKeystoreUseOptions) => Promise; accessState: () => Promise; /** Takes back a stored yes and this session's grants. Only ever narrows. */ revokeAccess: () => Promise; diff --git a/lib/plugin-sandbox/host-api-keystore.ts b/lib/plugin-sandbox/host-api-keystore.ts index f292cd6ad..cc3aff5d3 100644 --- a/lib/plugin-sandbox/host-api-keystore.ts +++ b/lib/plugin-sandbox/host-api-keystore.ts @@ -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 => { 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'); } diff --git a/lib/plugin-sandbox/permission-text.ts b/lib/plugin-sandbox/permission-text.ts index 6cac86eb4..0b8b58671 100644 --- a/lib/plugin-sandbox/permission-text.ts +++ b/lib/plugin-sandbox/permission-text.ts @@ -40,6 +40,20 @@ export const HIGH_RISK_PERMISSIONS: ReadonlySet = 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(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, '_'); diff --git a/lib/plugin-sandbox/permissions.ts b/lib/plugin-sandbox/permissions.ts index 892ee4d65..5c2088015 100644 --- a/lib/plugin-sandbox/permissions.ts +++ b/lib/plugin-sandbox/permissions.ts @@ -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 = 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); } diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 79e4e5a56..894e73da4 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -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, + 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, /** 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, + decrypt: (id: string, data: Uint8Array, scheme: unknown, options?: { quiet?: boolean }) => callApi('keystore.decrypt', [id, data, scheme, options], 0) as Promise, /** ECDH: the raw shared secret, big-endian, from the peer's SubjectPublicKeyInfo. */ - agree: (id: string, peerPublicKey: Uint8Array) => callApi('keystore.agree', [id, peerPublicKey], 0) as Promise, + agree: (id: string, peerPublicKey: Uint8Array, options?: { quiet?: boolean }) => callApi('keystore.agree', [id, peerPublicKey, options], 0) as Promise, }, /** The app the plugin runs in: product, the administrator's name for it, version, edition. No permission needed. */ app: { diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index c72ebd2d7..76991ebfb 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -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 diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 913662e62..e3584eef8 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -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()( // 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(IMPLICIT_PERMISSIONS); const granted = new Set(plugin.grantedPermissions ?? []); const missing = (plugin.permissions ?? []) @@ -401,6 +404,17 @@ export const usePluginStore = create()( 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 | ((state: PluginStoreState) => Partial)) => void, +): Promise { + 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 | ((state: PluginStoreState) => Partial)) => 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: {} }; }),