fix: sign plugin bundles when they are installed and require the signature for privileged plugins

This commit is contained in:
Linus Rath
2026-10-14 02:05:47 +02:00
parent e720be7e17
commit 592db209db
10 changed files with 131 additions and 38 deletions
+2 -2
View File
@@ -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) {
@@ -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/);
+2 -2
View File
@@ -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' });
});
+3 -3
View File
@@ -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');
});
@@ -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<boolean> {
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);
});
});
+37 -3
View File
@@ -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<boolean> {
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<string | null> {
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<string | null> {
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<string | null> {
const bundlePath = path.join(getPluginsDir(), `${id}.js`);
try {
+9 -1
View File
@@ -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<string> {
export async function downloadManagedBundle(
pluginId: string,
bundleHash?: string | null,
opts: { requireSignature?: boolean } = {},
): Promise<string> {
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`);
}
+3 -1
View File
@@ -100,7 +100,9 @@ async function getBundleCode(plugin: InstalledPlugin): Promise<string> {
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);
+9 -22
View File
@@ -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' };
}
+4 -4
View File
@@ -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<string | null> {
async function downloadPluginBundle(pluginId: string, bundleHash?: string, tier?: PluginTier): Promise<string | null> {
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;