diff --git a/app/api/admin/plugins/[id]/bundle/route.ts b/app/api/admin/plugins/[id]/bundle/route.ts index f2a4fc687..cb335a101 100644 --- a/app/api/admin/plugins/[id]/bundle/route.ts +++ b/app/api/admin/plugins/[id]/bundle/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry'; +import { getPluginBundle, getPlugin, getPluginSignature } from '@/lib/admin/plugin-registry'; import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev'; import { signBytes } from '@/lib/admin/plugin-signing'; @@ -65,7 +65,7 @@ export async function GET( }; if (etag) headers['ETag'] = etag; - const signature = await safeSign(code); + const signature = await getPluginSignature(id, code); if (signature) headers['X-Bundle-Signature'] = signature; if (etag && request.headers.get('if-none-match') === etag) { diff --git a/lib/__tests__/plugin-bundle-fetch.test.ts b/lib/__tests__/plugin-bundle-fetch.test.ts index 17bcf11f8..bac7bb5de 100644 --- a/lib/__tests__/plugin-bundle-fetch.test.ts +++ b/lib/__tests__/plugin-bundle-fetch.test.ts @@ -59,6 +59,14 @@ describe('downloadManagedBundle', () => { expect(console.warn).toHaveBeenCalled(); }); + it('refuses an unsigned bundle for a plugin that must be signed', async () => { + // Privileged plugins run with the app's own rights; without the + // install-time signature anyone who can write the bundle file decides + // what they run. + mocks.apiFetch.mockResolvedValue(response('code')); + await expect(downloadManagedBundle('my-plugin', 'abc', { requireSignature: true })).rejects.toThrow(/not signed/); + }); + it('reports an HTTP failure with the status', async () => { mocks.apiFetch.mockResolvedValue(response('', { ok: false, status: 404 })); await expect(downloadManagedBundle('my-plugin')).rejects.toThrow(/HTTP 404/); diff --git a/lib/__tests__/plugin-sandbox-loader.test.ts b/lib/__tests__/plugin-sandbox-loader.test.ts index 9a2bd91cc..307a5f2a5 100644 --- a/lib/__tests__/plugin-sandbox-loader.test.ts +++ b/lib/__tests__/plugin-sandbox-loader.test.ts @@ -104,7 +104,7 @@ describe('loadSandboxedPlugin bundle sourcing', () => { mocks.getCode.mockResolvedValue(null); mocks.downloadManagedBundle.mockResolvedValue(CODE); await loadSandboxedPlugin(plugin()); - expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', HASH); + expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', HASH, { requireSignature: false }); expect(mocks.saveCode).toHaveBeenCalledWith('managed-plugin', CODE); expect(lastStatus()).toMatchObject({ status: 'running' }); expect(getActive('managed-plugin')?.code).toBe(CODE); @@ -141,7 +141,7 @@ describe('loadSandboxedPlugin bundle sourcing', () => { it('accepts a managed bundle without a recorded hash (older records)', async () => { mocks.downloadManagedBundle.mockResolvedValue(CODE); await loadSandboxedPlugin(plugin({ bundleHash: undefined })); - expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', undefined); + expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', undefined, { requireSignature: false }); expect(lastStatus()).toMatchObject({ status: 'running' }); }); diff --git a/lib/__tests__/plugin-store.test.ts b/lib/__tests__/plugin-store.test.ts index 5b3d5a94a..5d3b4a56f 100644 --- a/lib/__tests__/plugin-store.test.ts +++ b/lib/__tests__/plugin-store.test.ts @@ -171,7 +171,7 @@ describe('usePluginStore', () => { it('installs a new server plugin and caches its bundle', async () => { await usePluginStore.getState().initializePlugins(); - expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', 'hash-v1'); + expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', 'hash-v1', { requireSignature: false }); expect(pluginStorage.saveCode).toHaveBeenCalledWith('managed-plugin', 'bundle-code'); const p = usePluginStore.getState().plugins.find(x => x.id === 'managed-plugin'); expect(p).toMatchObject({ managed: true, enabled: true, bundleHash: 'hash-v1' }); @@ -183,7 +183,7 @@ describe('usePluginStore', () => { await usePluginStore.getState().initializePlugins(); - expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', 'hash-v1'); + expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', 'hash-v1', { requireSignature: false }); expect(pluginStorage.saveCode).toHaveBeenCalledWith('managed-plugin', 'bundle-code'); expect(usePluginStore.getState().plugins).toHaveLength(1); }); @@ -204,7 +204,7 @@ describe('usePluginStore', () => { await usePluginStore.getState().initializePlugins(); - expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', 'hash-v1'); + expect(mocks.downloadManagedBundle).toHaveBeenCalledWith('managed-plugin', 'hash-v1', { requireSignature: false }); expect(usePluginStore.getState().plugins[0].bundleHash).toBe('hash-v1'); }); diff --git a/lib/admin/__tests__/plugin-registry-signature.test.ts b/lib/admin/__tests__/plugin-registry-signature.test.ts new file mode 100644 index 000000000..248747836 --- /dev/null +++ b/lib/admin/__tests__/plugin-registry-signature.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPublicKey, verify } from 'node:crypto'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { tmpdir } from 'node:os'; + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), 'bw-plugin-registry-')); + vi.stubEnv('ADMIN_CONFIG_DIR', dir); + vi.resetModules(); +}); + +afterEach(async () => { + vi.unstubAllEnvs(); + await rm(dir, { recursive: true, force: true }); +}); + +async function verifies(code: string, signature: string): Promise { + const pem = await readFile(path.join(dir, 'plugin-signing.key'), 'utf-8'); + const publicKey = createPublicKey(pem); + return verify(null, Buffer.from(code, 'utf-8'), publicKey, Buffer.from(signature, 'base64')); +} + +const PLUGIN = { + id: 'signed-demo', name: 'S', version: '1.0.0', author: 'a', description: '', type: 'hook', + permissions: [], entrypoint: 'index.js', enabled: true, installedAt: 'x', updatedAt: 'x', +}; + +describe('plugin bundle signatures', () => { + it('signs the bundle when it is installed', async () => { + const registry = await import('../plugin-registry'); + await registry.savePlugin(PLUGIN, 'module.exports = {}'); + + const signature = await registry.getPluginSignature('signed-demo', 'module.exports = {}'); + + expect(signature).toBeTruthy(); + expect(await verifies('module.exports = {}', signature!)).toBe(true); + }); + + it('keeps the install-time signature when the file on disk changes', async () => { + // Signing at serve time signed whatever was on disk, so a swapped + // bundle still came with a valid signature. + const registry = await import('../plugin-registry'); + await registry.savePlugin(PLUGIN, 'module.exports = {}'); + await writeFile(path.join(dir, 'plugins', 'signed-demo.js'), 'stealEverything()', 'utf-8'); + + const swapped = (await registry.getPluginBundle('signed-demo'))!; + const signature = await registry.getPluginSignature('signed-demo', swapped); + + expect(await verifies(swapped, signature!)).toBe(false); + }); +}); diff --git a/lib/admin/plugin-registry.ts b/lib/admin/plugin-registry.ts index 49bc072d7..4f1a7a03e 100644 --- a/lib/admin/plugin-registry.ts +++ b/lib/admin/plugin-registry.ts @@ -205,6 +205,10 @@ export async function savePlugin( // Save code bundle const bundlePath = path.join(dir, `${plugin.id}.js`); await writeFile(bundlePath, code, 'utf-8'); + // Sign now and keep the signature with the bundle. Signing at serve time + // would sign whatever is on disk, so a bundle file swapped after install + // would still verify. + await storePluginSignature(plugin.id, code); // Stamp content hash + updatedAt so clients can detect re-uploads even // when the manifest version hasn't changed. Preserve the original @@ -249,13 +253,43 @@ export async function deletePlugin(id: string): Promise { registry.plugins.splice(idx, 1); await writeJsonFile(pluginRegistryPath(), registry); - // Remove bundle file - const bundlePath = path.join(getPluginsDir(), `${id}.js`); - try { await unlink(bundlePath); } catch { /* ok if missing */ } + // Remove bundle and signature files + for (const file of [`${id}.js`, `${id}.sig`]) { + try { await unlink(path.join(getPluginsDir(), file)); } catch { /* ok if missing */ } + } return true; } +async function storePluginSignature(id: string, code: string): Promise { + const sigPath = path.join(getPluginsDir(), `${id}.sig`); + try { + const { signBytes } = await import('./plugin-signing'); + const signature = await signBytes(code); + await writeFile(sigPath, signature, 'utf-8'); + return signature; + } catch (err) { + // Without a key the bundle still installs; it just can't run privileged. + try { await unlink(sigPath); } catch { /* none */ } + logger.warn('[plugin-registry] could not sign plugin bundle', { id, error: err instanceof Error ? err.message : String(err) }); + return null; + } +} + +/** + * The signature made when the bundle was installed. Bundles installed before + * signatures were stored get one on first request (trust on first use), so + * a later change to the file on disk stops verifying from then on. + */ +export async function getPluginSignature(id: string, code: string): Promise { + if (!SAFE_ID_RE.test(id)) return null; + try { + return (await readFile(path.join(getPluginsDir(), `${id}.sig`), 'utf-8')).trim() || null; + } catch { + return storePluginSignature(id, code); + } +} + export async function getPluginBundle(id: string): Promise { const bundlePath = path.join(getPluginsDir(), `${id}.js`); try { diff --git a/lib/plugin-sandbox/bundle-fetch.ts b/lib/plugin-sandbox/bundle-fetch.ts index 031fe2cc8..fb9b90619 100644 --- a/lib/plugin-sandbox/bundle-fetch.ts +++ b/lib/plugin-sandbox/bundle-fetch.ts @@ -28,7 +28,11 @@ export function bundleUrl(pluginId: string, bundleHash?: string | null): string * load-time error shows up on the plugin card instead of the plugin silently * vanishing from the list. */ -export async function downloadManagedBundle(pluginId: string, bundleHash?: string | null): Promise { +export async function downloadManagedBundle( + pluginId: string, + bundleHash?: string | null, + opts: { requireSignature?: boolean } = {}, +): Promise { let res: Response; try { res = await apiFetch(bundleUrl(pluginId, bundleHash)); @@ -55,6 +59,10 @@ export async function downloadManagedBundle(pluginId: string, bundleHash?: strin if (!ok) { throw new Error(`Refusing the bundle for plugin "${pluginId}": signature verification failed`); } + } else if (opts.requireSignature) { + // A privileged plugin runs with full access to the app; it only loads + // with a signature made when the admin installed it. + throw new Error(`Refusing the bundle for plugin "${pluginId}": it is not signed`); } else { console.warn(`[plugin-bundle] Bundle for "${pluginId}" has no Ed25519 signature; loading without it`); } diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index e5f8b4706..9172915e7 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -100,7 +100,9 @@ async function getBundleCode(plugin: InstalledPlugin): Promise { throw new Error(`No bundle in storage for plugin "${plugin.id}". Reinstall to populate.`); } - const code = await downloadManagedBundle(plugin.id, plugin.bundleHash); + const code = await downloadManagedBundle(plugin.id, plugin.bundleHash, { + requireSignature: plugin.tier === 'privileged', + }); await verifyBundle(code, plugin.bundleHash); try { await pluginStorage.saveCode(plugin.id, code); diff --git a/lib/plugin-sandbox/tier.ts b/lib/plugin-sandbox/tier.ts index 2861e2b91..1c92777fc 100644 --- a/lib/plugin-sandbox/tier.ts +++ b/lib/plugin-sandbox/tier.ts @@ -23,14 +23,10 @@ export type TierResolution = * * Privileged tier gates (ALL required): * 1. Manifest declares the umbrella high-risk permission `crypto:full`. - * 2. Signed bundle: only bundles delivered through the admin/server channel - * are Ed25519-signed (verified at download time — see `verifySignature` - * usage in the plugin store). Self-uploaded bundles are unsigned and can - * therefore never reach the privileged tier. `managed` is the signal that - * the bundle came through that signed channel. - * 3. Admin approval pins operator trust in this specific bundle. - * 4. Explicit high-risk consent for `crypto:full` (granted via the consent - * dialog at enable time; admin-managed plugins are pre-approved). + * 2. An admin installed it (`managed`). Admin installs are the operator's + * decision, so there is no separate approval or per-user consent step. + * 3. Its bundle verifies against the signature made at install time; the + * download refuses an unsigned or mis-signed privileged bundle. */ export function resolvePluginTier(plugin: InstalledPlugin): TierResolution { if (plugin.tier !== 'privileged') { @@ -42,23 +38,14 @@ export function resolvePluginTier(plugin: InstalledPlugin): TierResolution { return { tier: null, error: 'Privileged tier requires the "crypto:full" permission' }; } - // 2 + 3. Trust root: signed (managed) bundle AND admin approval. A bundle - // uploaded by the user directly carries no signature, so it cannot be - // privileged regardless of what its manifest claims. + // 2. Only an admin can install a privileged plugin. A plugin the user + // uploaded is never privileged, whatever its manifest claims; installing + // over an admin plugin's id is refused (see installPlugin). The bundle + // itself must carry the signature made at admin install time, which the + // bundle download checks for privileged plugins (downloadManagedBundle). if (!plugin.managed) { return { tier: null, error: 'Privileged tier requires a signed bundle delivered through the admin channel' }; } - if (!(plugin.adminApproved || plugin.managed)) { - return { tier: null, error: 'Privileged tier requires administrator approval' }; - } - - // 4. Explicit high-risk consent. Admin-managed plugins are pre-approved by - // the operator and skip the per-user prompt (mirrors the existing consent - // gate in the plugin store); otherwise the user must have granted crypto:full. - const consented = plugin.managed || (plugin.grantedPermissions ?? []).includes('crypto:full'); - if (!consented) { - return { tier: null, error: 'Privileged tier requires explicit consent for "crypto:full"' }; - } return { tier: 'privileged' }; } diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 14dc04589..28fcd32fd 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -476,7 +476,7 @@ async function syncServerPlugins( if (!local) { // New server plugin - download bundle and install. - const code = await downloadPluginBundle(sp.id, sp.bundleHash); + const code = await downloadPluginBundle(sp.id, sp.bundleHash, sp.tier); if (!code) continue; await pluginStorage.saveCode(sp.id, code); @@ -521,7 +521,7 @@ async function syncServerPlugins( (!!sp.bundleHash && local.bundleHash !== sp.bundleHash); if (needsBundle) { - const code = await downloadPluginBundle(sp.id, sp.bundleHash); + const code = await downloadPluginBundle(sp.id, sp.bundleHash, sp.tier); if (!code) continue; await pluginStorage.saveCode(sp.id, code); } @@ -572,9 +572,9 @@ async function syncServerPlugins( * The sandbox loader shares the same download path * (`lib/plugin-sandbox/bundle-fetch`) to refill a missing bundle at load time. */ -async function downloadPluginBundle(pluginId: string, bundleHash?: string): Promise { +async function downloadPluginBundle(pluginId: string, bundleHash?: string, tier?: PluginTier): Promise { try { - return await downloadManagedBundle(pluginId, bundleHash); + return await downloadManagedBundle(pluginId, bundleHash, { requireSignature: tier === 'privileged' }); } catch (err) { console.error(`[plugin-store] ${err instanceof Error ? err.message : String(err)}`); return null;