From 67320432ca7deedc59de2dd0970803df642465d2 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Fri, 28 Aug 2026 00:30:43 -0700 Subject: [PATCH] feat(plugins): ed25519 release-key verification and plugin add/remove (9b) Ships ed25519 signature verification for plugins with two new components: - src/plugins/keys.ts: RELEASE_PUBLIC_KEYS map with one release keypair - scripts/sign-plugin.mjs: keygen and sign commands for plugin developers - verifyPlugin() in loader.ts: validates ed25519 signatures, rejects unsigned plugins unless CODEBURN_PLUGIN_DEV=1 - plugin add : installs signed plugins to ~/.config/codeburn/plugins/ - plugin remove --confirm: removes installed plugins All three gates pass: tsc clean, vitest zero new failures, smoke test complete. Design decision: public keys stored as base64-encoded PEM format rather than raw 32-byte keys. Node.js crypto.verify requires PEM/DER format or KeyObject for ed25519; raw bytes alone fail. PEM is standard and portable. Private key written to: /tmp/codeburn-signing/codeburn-signing-key.pem --- scripts/sign-plugin.mjs | 136 +++++++++++++ scripts/smoke-plugin-socket.mjs | 41 ++++ src/plugins/cli.ts | 57 +++++- src/plugins/keys.ts | 6 + src/plugins/loader.ts | 108 +++++++++- tests/plugin-signing.test.ts | 335 ++++++++++++++++++++++++++++++++ tests/plugin-socket.test.ts | 2 +- 7 files changed, 679 insertions(+), 6 deletions(-) create mode 100644 scripts/sign-plugin.mjs create mode 100644 src/plugins/keys.ts create mode 100644 tests/plugin-signing.test.ts diff --git a/scripts/sign-plugin.mjs b/scripts/sign-plugin.mjs new file mode 100644 index 00000000..9665b298 --- /dev/null +++ b/scripts/sign-plugin.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +/** + * Plugin signing utility: keygen and sign. + * + * keygen --out - generate a keypair, print keyId + public key, write private PEM + * sign - sign a plugin directory using CODEBURN_SIGNING_KEY env var + */ + +import { createPrivateKey, createPublicKey, randomBytes } from 'crypto' +import { readdir, readFile, stat, writeFile } from 'fs/promises' +import { join } from 'path' +import process from 'process' + +const command = process.argv[2] + +if (command === 'keygen') { + await handleKeygen() +} else if (command === 'sign') { + await handleSign() +} else { + console.error('Usage: node scripts/sign-plugin.mjs keygen --out ') + console.error(' node scripts/sign-plugin.mjs sign ') + process.exit(1) +} + +async function handleKeygen() { + const outIdx = process.argv.indexOf('--out') + if (outIdx < 0 || outIdx + 1 >= process.argv.length) { + console.error('Usage: node scripts/sign-plugin.mjs keygen --out ') + process.exit(1) + } + const outFile = process.argv[outIdx + 1] + + // Generate a new keypair + const { generateKeyPairSync } = await import('crypto') + const { privateKey: privKeyObj, publicKey: pubKeyObj } = generateKeyPairSync('ed25519') + + // Export keys in appropriate formats + const privateKeyPem = privKeyObj.export({ format: 'pem', type: 'pkcs8' }) + const publicKeyPem = pubKeyObj.export({ format: 'pem', type: 'spki' }) + + // Base64 encode the PEM public key for storage + const pubKeyBase64 = Buffer.from(publicKeyPem).toString('base64') + + // Generate keyId as 8 hex chars from random bytes + const keyIdBytes = randomBytes(4) + const keyId = keyIdBytes.toString('hex').substring(0, 8) + + // Write private key PEM to file + await writeFile(outFile, privateKeyPem, 'utf8') + + // Print to stdout + console.log(`keyId: ${keyId}`) + console.log(`public: ${pubKeyBase64}`) +} + +async function handleSign() { + const pluginDir = process.argv[3] + if (!pluginDir) { + console.error('Usage: node scripts/sign-plugin.mjs sign ') + process.exit(1) + } + + const sigKeyPath = process.env.CODEBURN_SIGNING_KEY + if (!sigKeyPath) { + console.error('CODEBURN_SIGNING_KEY not set') + process.exit(1) + } + + // Read the plugin manifest + const manifestFile = join(pluginDir, 'codeburn-plugin.json') + const manifestRaw = JSON.parse(await readFile(manifestFile, 'utf8')) + const { name, version } = manifestRaw + if (!name || !version) { + console.error('Plugin manifest missing name or version') + process.exit(1) + } + + // Get file list: all regular files except codeburn-plugin.sig + const files = await getFilesList(pluginDir) + + // Build the canonical signing digest + const digest = computeDigest(name, version, files) + + // Read private key and sign + const { sign } = await import('crypto') + const privKeyPem = await readFile(sigKeyPath, 'utf8') + const privKey = createPrivateKey(privKeyPem) + const signature = sign(null, Buffer.from(digest), privKey) + const signatureBase64 = signature.toString('base64') + + // Extract keyId from the public key derived from the private key + const pubKey = createPublicKey(privKey) + const pubKeyPem = pubKey.export({ format: 'pem', type: 'spki' }) + // Hash the PEM to get a consistent keyId + const { createHash } = await import('crypto') + const keyIdBytes = createHash('sha256').update(pubKeyPem).digest().slice(0, 4) + const keyId = keyIdBytes.toString('hex') + + // Write signature file + const sigFile = join(pluginDir, 'codeburn-plugin.sig') + const sigData = { + alg: 'ed25519', + keyId, + signature: signatureBase64, + } + await writeFile(sigFile, JSON.stringify(sigData), 'utf8') + console.log(`Signed ${pluginDir}`) +} + +async function getFilesList(dir) { + const files = [] + const entries = await readdir(dir, { withFileTypes: true }) + for (const entry of entries) { + if (entry.isFile() && entry.name !== 'codeburn-plugin.sig') { + const fullPath = join(dir, entry.name) + const stat_info = await stat(fullPath) + if (!stat_info.isFile()) continue + const content = await readFile(fullPath) + const sha256 = await hashSha256(content) + files.push({ path: entry.name, sha256 }) + } + } + files.sort((a, b) => a.path.localeCompare(b.path)) + return files +} + +function computeDigest(name, version, files) { + const canonical = JSON.stringify({ name, version, files }) + return canonical +} + +async function hashSha256(data) { + const crypto = await import('crypto') + return crypto.createHash('sha256').update(data).digest('hex') +} diff --git a/scripts/smoke-plugin-socket.mjs b/scripts/smoke-plugin-socket.mjs index ba2cbc74..d788783c 100644 --- a/scripts/smoke-plugin-socket.mjs +++ b/scripts/smoke-plugin-socket.mjs @@ -104,6 +104,47 @@ async function main() { await rm(pluginDir, { recursive: true, force: true }) } + // 7) add/remove flow with CODEBURN_PLUGIN_DEV=1 + const addRemoveDir = await mkdtemp(join(tmpdir(), 'smoke-add-remove-')) + try { + const sourceDir = join(addRemoveDir, 'source') + const installDir = join(addRemoveDir, 'installed') + await mkdir(sourceDir, { recursive: true }) + await mkdir(installDir, { recursive: true }) + await writeFile(join(sourceDir, 'codeburn-plugin.json'), JSON.stringify({ + name: 'dev-plugin', version: '0.1.0', cliCompat: '>=0.9.22', + capabilities: { commands: [], syncAttributes: [], payloadSections: [], spanKinds: [] }, + })) + await writeFile(join(sourceDir, 'test.txt'), 'test content') + + // Add plugin with dev flag (unsigned) + const addResult = await run(['plugin', 'add', sourceDir, '--dir', installDir], { env: { CODEBURN_PLUGIN_DEV: '1' } }) + assertEq(addResult.code, 0, '`plugin add` succeeds with CODEBURN_PLUGIN_DEV=1') + assertContains(addResult.stdout, 'dev-plugin@0.1.0', '`plugin add` confirms installation') + + // List should show the added plugin + const listAfterAdd = await run(['plugin', 'list', '--dir', installDir], { env: { CODEBURN_PLUGIN_DEV: '1' } }) + assertEq(listAfterAdd.code, 0, '`plugin list` after add exits 0') + assertContains(listAfterAdd.stdout, 'loaded dev-plugin@0.1.0', '`plugin list` shows added plugin') + + // Remove without --confirm should fail + const removeNoConfirm = await run(['plugin', 'remove', 'dev-plugin', '--dir', installDir]) + assertEq(removeNoConfirm.code, 1, '`plugin remove` without --confirm exits 1') + assertContains(removeNoConfirm.stdout, 'Would remove', '`plugin remove` prints confirmation prompt') + + // Remove with --confirm should succeed + const removeConfirm = await run(['plugin', 'remove', 'dev-plugin', '--confirm', '--dir', installDir]) + assertEq(removeConfirm.code, 0, '`plugin remove --confirm` succeeds') + assertContains(removeConfirm.stdout, 'removed', '`plugin remove` confirms removal') + + // List should be empty after removal + const listAfterRemove = await run(['plugin', 'list', '--dir', installDir]) + assertEq(listAfterRemove.code, 0, '`plugin list` after remove exits 0') + assertContains(listAfterRemove.stdout, 'No plugins', '`plugin list` shows empty') + } finally { + await rm(addRemoveDir, { recursive: true, force: true }) + } + if (process.exitCode === 1) { console.error('\nSMOKE FAILED') } else { diff --git a/src/plugins/cli.ts b/src/plugins/cli.ts index 3436fc4e..fb5beb75 100644 --- a/src/plugins/cli.ts +++ b/src/plugins/cli.ts @@ -11,8 +11,9 @@ */ import type { Command } from 'commander' -import { stat } from 'fs/promises' +import { stat, mkdir, readFile, writeFile, rm, readdir } from 'fs/promises' import { join } from 'path' +import { homedir } from 'os' import { defaultPluginsDir, loadPlugins, currentCliVersion, verifyPlugin, readPluginManifestRaw } from './loader.js' import { parsePluginManifest, type PluginManifest } from './manifest.js' @@ -88,6 +89,60 @@ export function registerPluginCommands(program: Command): void { throw new Error(`unverified ${name}@${manifest.version} ${result.reason ?? 'verification failed'}`) } }) + + plugin + .command('add ') + .description('Install a plugin from a source directory') + .option('--dir ', 'Override the plugins directory') + .action(async (sourcePath: string, opts: { dir?: string }) => { + const pluginsDir = opts.dir ?? defaultPluginsDir() + const { raw, reason } = await readPluginManifestRaw(sourcePath) + if (reason) { + throw new Error(`Could not read manifest from ${sourcePath}: ${reason}`) + } + const parsed = parsePluginManifest(raw, `${sourcePath}/codeburn-plugin.json`) + if (!parsed.ok) { + throw new Error(`Invalid manifest: ${parsed.reason}`) + } + const manifest = parsed.manifest + const verified = await verifyPlugin(sourcePath, manifest, process.env) + if (!verified.ok) { + throw new Error(`Plugin verification failed: ${verified.reason ?? 'unknown reason'}`) + } + const destDir = join(pluginsDir, manifest.name) + try { + await stat(destDir) + throw new Error(`Plugin "${manifest.name}" already installed at ${destDir}`) + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err + } + await mkdir(destDir, { recursive: true }) + const entries = await readdir(sourcePath, { withFileTypes: true }) + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (!entry.isFile()) continue + const srcFile = join(sourcePath, entry.name) + const destFile = join(destDir, entry.name) + const content = await readFile(srcFile) + await writeFile(destFile, content) + } + process.stdout.write(`Plugin "${manifest.name}@${manifest.version}" installed to ${destDir}\n`) + }) + + plugin + .command('remove ') + .description('Remove an installed plugin') + .option('--dir ', 'Override the plugins directory') + .option('--confirm', 'Confirm removal') + .action(async (name: string, opts: { dir?: string, confirm?: boolean }) => { + const pluginsDir = opts.dir ?? defaultPluginsDir() + const destDir = join(pluginsDir, name) + if (!opts.confirm) { + process.stdout.write(`Would remove plugin directory: ${destDir}\nUse --confirm to proceed.\n`) + process.exit(1) + } + await rm(destDir, { recursive: true, force: true }) + process.stdout.write(`Plugin "${name}" removed.\n`) + }) } /// Reads the manifest at /codeburn-plugin.json and parses it via the diff --git a/src/plugins/keys.ts b/src/plugins/keys.ts new file mode 100644 index 00000000..8ada3a14 --- /dev/null +++ b/src/plugins/keys.ts @@ -0,0 +1,6 @@ +/// Release public keys for ed25519 plugin signature verification. +/// Maps keyId (8 hex chars) to base64-encoded PEM public key. + +export const RELEASE_PUBLIC_KEYS: ReadonlyMap = new Map([ + ['f40248d0', 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUNvd0JRWURLMlZ3QXlFQTU1QTAvMUpLTlBoMGFsL2xMN2xhWFBobWVnZVhzUENqK3RoM1B4LzNWM0U9Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo='], +]) diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index a7517dfb..62306f7c 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -15,8 +15,10 @@ import { readdir, readFile, stat } from 'fs/promises' import { join } from 'path' import { homedir } from 'os' import { createRequire } from 'module' +import { verify as cryptoVerify } from 'crypto' import { checkCliCompat, parsePluginManifest, type PluginManifest } from './manifest.js' +import { RELEASE_PUBLIC_KEYS } from './keys.js' /// Resolves from BOTH layouts: src/plugins/loader.ts during tests/tsx and /// the flattened dist/main.ts bundle at runtime (tsup keeps one file). @@ -48,11 +50,109 @@ export function defaultPluginsDir(): string { /// Unsigned plugins are refused unless CODEBURN_PLUGIN_DEV=1, and even then /// only for local development (this flag is expected to be absent in every /// real environment). -export async function verifyPlugin(dir: string, manifest: PluginManifest, env: NodeJS.ProcessEnv): Promise<{ ok: boolean, reason?: string }> { +export async function verifyPlugin( + dir: string, + manifest: PluginManifest, + env: NodeJS.ProcessEnv, + knownKeys?: ReadonlyMap, +): Promise<{ ok: boolean, reason?: string }> { if (env.CODEBURN_PLUGIN_DEV === '1') return { ok: true } - void dir - void manifest - return { ok: false, reason: 'unsigned plugin (signature verification arrives in 9b; CODEBURN_PLUGIN_DEV=1 allows unsigned plugins for local development only)' } + + const keys = knownKeys ?? RELEASE_PUBLIC_KEYS + + // Read the signature file + const sigFile = join(dir, 'codeburn-plugin.sig') + let sigData: { alg?: string, keyId?: string, signature?: string } + try { + const content = await readFile(sigFile, 'utf8') + sigData = JSON.parse(content) + } catch { + return { ok: false, reason: 'missing signature file' } + } + + if (sigData.alg !== 'ed25519') { + return { ok: false, reason: 'unknown signature algorithm' } + } + + const keyId = sigData.keyId + if (!keyId || typeof keyId !== 'string') { + return { ok: false, reason: 'missing key id' } + } + + if (!keys.has(keyId)) { + return { ok: false, reason: 'unknown key id' } + } + + // Check for symlinks + const hasSymlink = await checkForSymlinks(dir) + if (hasSymlink) { + return { ok: false, reason: 'symlink present' } + } + + // Get file list + const files = await getPluginFilesList(dir) + + // Compute canonical digest + const canonical = JSON.stringify({ + name: manifest.name, + version: manifest.version, + files, + }) + + // Get public key and verify signature + const pubKeyBase64 = keys.get(keyId)! + const pubKeyPem = Buffer.from(pubKeyBase64, 'base64').toString('utf8') + + const signatureBuffer = Buffer.from(sigData.signature ?? '', 'base64') + try { + const isValid = cryptoVerify(null, Buffer.from(canonical), pubKeyPem, signatureBuffer) + if (!isValid) { + return { ok: false, reason: 'bad signature' } + } + } catch { + return { ok: false, reason: 'bad signature' } + } + + return { ok: true } +} + +async function checkForSymlinks(dir: string): Promise { + try { + const entries = await readdir(dir, { withFileTypes: true }) + for (const entry of entries) { + const fullPath = join(dir, entry.name) + if (entry.isSymbolicLink()) return true + } + } catch { + return true + } + return false +} + +async function getPluginFilesList( + dir: string, +): Promise> { + const { createHash } = await import('crypto') + const files: Array<{ path: string, sha256: string }> = [] + try { + const entries = await readdir(dir, { withFileTypes: true }) + for (const entry of entries) { + if (entry.name === 'codeburn-plugin.sig') continue + if (!entry.isFile()) continue + const fullPath = join(dir, entry.name) + try { + const content = await readFile(fullPath) + const hash = createHash('sha256').update(content).digest('hex') + files.push({ path: entry.name, sha256: hash }) + } catch { + continue + } + } + } catch { + return [] + } + files.sort((a, b) => a.path.localeCompare(b.path)) + return files } async function readManifest(dir: string): Promise<{ raw?: unknown, reason?: string }> { diff --git a/tests/plugin-signing.test.ts b/tests/plugin-signing.test.ts new file mode 100644 index 00000000..e57b46eb --- /dev/null +++ b/tests/plugin-signing.test.ts @@ -0,0 +1,335 @@ +/** + * Tests for ed25519 plugin signature verification and add/remove commands (9b). + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, rm, mkdir, writeFile, readFile } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' +import { generateKeyPairSync, createPublicKey, sign } from 'crypto' + +import { verifyPlugin } from '../src/plugins/loader.js' +import { parsePluginManifest } from '../src/plugins/manifest.js' + +function validManifest(name = 'sample') { + return { + name, + version: '0.1.0', + cliCompat: '>=0.9.22', + capabilities: { + commands: [], + syncAttributes: [], + payloadSections: [], + spanKinds: [], + }, + } +} + +async function generateTestKeyPair() { + const { publicKey, privateKey } = generateKeyPairSync('ed25519', { + publicKeyEncoding: { format: 'pem', type: 'spki' }, + privateKeyEncoding: { format: 'pem', type: 'pkcs8' }, + }) + // Base64-encode the PEM public key + return { + publicKeyBase64: Buffer.from(publicKey as string).toString('base64'), + privateKeyPem: privateKey as string, + } +} + +async function computeDigest(name: string, version: string, files: Array<{ path: string, sha256: string }>) { + return JSON.stringify({ name, version, files }) +} + +async function signPlugin(pluginDir: string, keyPem: string, keyId: string) { + const { createPrivateKey, createHash } = await import('crypto') + const { readdir } = await import('fs/promises') + const manifest = JSON.parse(await readFile(join(pluginDir, 'codeburn-plugin.json'), 'utf8')) + const { name, version } = manifest + + const entries = await readdir(pluginDir, { withFileTypes: true }) + const files: Array<{ path: string, sha256: string }> = [] + for (const entry of entries) { + if (!entry.isFile() || entry.name === 'codeburn-plugin.sig') continue + const content = await readFile(join(pluginDir, entry.name)) + const hash = createHash('sha256').update(content).digest('hex') + files.push({ path: entry.name, sha256: hash }) + } + files.sort((a, b) => a.path.localeCompare(b.path)) + + const canonical = JSON.stringify({ name, version, files }) + const privKey = createPrivateKey(keyPem) + const signature = sign(null, Buffer.from(canonical), privKey) + + const sigData = { + alg: 'ed25519', + keyId, + signature: signature.toString('base64'), + } + await writeFile(join(pluginDir, 'codeburn-plugin.sig'), JSON.stringify(sigData), 'utf8') +} + +let tmpDir: string +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'plugin-signing-')) +}) +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +describe('plugin signature verification (9b)', () => { + it('keygen -> sign -> verify ok round trip with ephemeral key', async () => { + const { publicKeyBase64, privateKeyPem } = await generateTestKeyPair() + const keyId = '12345678' + const knownKeys = new Map([[keyId, publicKeyBase64]]) + + const pluginDir = join(tmpDir, 'good') + await mkdir(pluginDir, { recursive: true }) + await writeFile(join(pluginDir, 'codeburn-plugin.json'), JSON.stringify(validManifest('good'))) + await writeFile(join(pluginDir, 'file1.txt'), 'content1') + + await signPlugin(pluginDir, privateKeyPem, keyId) + + const manifest = validManifest('good') + const result = await verifyPlugin(pluginDir, manifest, process.env, knownKeys) + expect(result.ok).toBe(true) + }) + + it('tampering a file after signing results in digest mismatch', async () => { + const { publicKeyBase64, privateKeyPem } = await generateTestKeyPair() + const keyId = '12345678' + const knownKeys = new Map([[keyId, publicKeyBase64]]) + + const pluginDir = join(tmpDir, 'tampered') + await mkdir(pluginDir, { recursive: true }) + await writeFile(join(pluginDir, 'codeburn-plugin.json'), JSON.stringify(validManifest('tampered'))) + await writeFile(join(pluginDir, 'file1.txt'), 'content1') + + await signPlugin(pluginDir, privateKeyPem, keyId) + + // Tamper with a file + await writeFile(join(pluginDir, 'file1.txt'), 'modified content') + + const manifest = validManifest('tampered') + const result = await verifyPlugin(pluginDir, manifest, process.env, knownKeys) + expect(result.ok).toBe(false) + expect(result.reason).toMatch(/signature|digest/) + }) + + it('unknown keyId is rejected', async () => { + const { publicKeyBase64, privateKeyPem } = await generateTestKeyPair() + const keyId = '12345678' + const knownKeys = new Map([['ffffffff', publicKeyBase64]]) + + const pluginDir = join(tmpDir, 'unknown') + await mkdir(pluginDir, { recursive: true }) + await writeFile(join(pluginDir, 'codeburn-plugin.json'), JSON.stringify(validManifest('unknown'))) + await writeFile(join(pluginDir, 'file1.txt'), 'content1') + + await signPlugin(pluginDir, privateKeyPem, keyId) + + const manifest = validManifest('unknown') + const result = await verifyPlugin(pluginDir, manifest, process.env, knownKeys) + expect(result.ok).toBe(false) + expect(result.reason).toMatch(/unknown key|key id/) + }) + + it('missing signature file without dev flag is rejected', async () => { + const pluginDir = join(tmpDir, 'unsigned') + await mkdir(pluginDir, { recursive: true }) + await writeFile(join(pluginDir, 'codeburn-plugin.json'), JSON.stringify(validManifest('unsigned'))) + + const env = { ...process.env } + delete env.CODEBURN_PLUGIN_DEV + + const manifest = validManifest('unsigned') + const result = await verifyPlugin(pluginDir, manifest, env) + expect(result.ok).toBe(false) + expect(result.reason).toMatch(/signature|unsigned/) + }) + + it('missing signature file with CODEBURN_PLUGIN_DEV=1 is accepted', async () => { + const pluginDir = join(tmpDir, 'dev-unsigned') + await mkdir(pluginDir, { recursive: true }) + await writeFile(join(pluginDir, 'codeburn-plugin.json'), JSON.stringify(validManifest('dev-unsigned'))) + + const env = { ...process.env, CODEBURN_PLUGIN_DEV: '1' } + + const manifest = validManifest('dev-unsigned') + const result = await verifyPlugin(pluginDir, manifest, env) + expect(result.ok).toBe(true) + }) + + it('symlink in directory causes verification failure', async () => { + const { publicKeyBase64, privateKeyPem } = await generateTestKeyPair() + const keyId = '12345678' + const knownKeys = new Map([[keyId, publicKeyBase64]]) + + const pluginDir = join(tmpDir, 'symlink') + await mkdir(pluginDir, { recursive: true }) + await writeFile(join(pluginDir, 'codeburn-plugin.json'), JSON.stringify(validManifest('symlink'))) + await writeFile(join(pluginDir, 'file1.txt'), 'content1') + + await signPlugin(pluginDir, privateKeyPem, keyId) + + // Add a symlink (this will cause verification to fail) + const targetFile = join(tmpDir, 'target.txt') + await writeFile(targetFile, 'target content') + const { symlink } = await import('fs/promises') + await symlink(targetFile, join(pluginDir, 'link.txt')) + + const manifest = validManifest('symlink') + const result = await verifyPlugin(pluginDir, manifest, process.env, knownKeys) + expect(result.ok).toBe(false) + expect(result.reason).toMatch(/symlink/) + }) +}) + +describe('plugin add/remove commands (9b)', () => { + it('plugin add refuses unsigned source without dev flag', async () => { + const { Command } = (await import('commander')).default ?? (await import('commander')) + const { registerPluginCommands } = await import('../src/plugins/cli.js') + + const sourceDir = join(tmpDir, 'source') + const pluginDir = await mkdtemp(join(tmpdir(), 'plugins-')) + + await mkdir(sourceDir, { recursive: true }) + await writeFile(join(sourceDir, 'codeburn-plugin.json'), JSON.stringify(validManifest('unsigned-plugin'))) + await writeFile(join(sourceDir, 'file1.txt'), 'content1') + + const program = new Command() + program.exitOverride() + registerPluginCommands(program) + + const env = { ...process.env } + delete env.CODEBURN_PLUGIN_DEV + + try { + await expect( + program.parseAsync(['node', 'codeburn', 'plugin', 'add', sourceDir, '--dir', pluginDir]), + ).rejects.toThrow() + } finally { + await rm(pluginDir, { recursive: true, force: true }) + } + }) + + it('plugin add succeeds on signed source', async () => { + const { Command } = (await import('commander')).default ?? (await import('commander')) + const { registerPluginCommands } = await import('../src/plugins/cli.js') + const { publicKeyBase64, privateKeyPem } = await generateTestKeyPair() + const keyId = '12345678' + + const sourceDir = join(tmpDir, 'signed-source') + const pluginDir = await mkdtemp(join(tmpdir(), 'plugins-')) + + await mkdir(sourceDir, { recursive: true }) + await writeFile(join(sourceDir, 'codeburn-plugin.json'), JSON.stringify(validManifest('signed-plugin'))) + await writeFile(join(sourceDir, 'file1.txt'), 'content1') + + await signPlugin(sourceDir, privateKeyPem, keyId) + + // Temporarily override RELEASE_PUBLIC_KEYS for this test + const { RELEASE_PUBLIC_KEYS } = await import('../src/plugins/keys.js') + const originalKeys = new Map(RELEASE_PUBLIC_KEYS) + ;(RELEASE_PUBLIC_KEYS as any).set(keyId, publicKeyBase64) + + const program = new Command() + program.exitOverride() + registerPluginCommands(program) + + try { + await program.parseAsync(['node', 'codeburn', 'plugin', 'add', sourceDir, '--dir', pluginDir]) + const destDir = join(pluginDir, 'signed-plugin') + const installedManifest = JSON.parse(await readFile(join(destDir, 'codeburn-plugin.json'), 'utf8')) + expect(installedManifest.name).toBe('signed-plugin') + } finally { + // Restore original keys + RELEASE_PUBLIC_KEYS.clear() + for (const [k, v] of originalKeys) { + ;(RELEASE_PUBLIC_KEYS as any).set(k, v) + } + await rm(pluginDir, { recursive: true, force: true }) + } + }) + + it('plugin add refuses existing destination', async () => { + const { Command } = (await import('commander')).default ?? (await import('commander')) + const { registerPluginCommands } = await import('../src/plugins/cli.js') + const { publicKeyBase64, privateKeyPem } = await generateTestKeyPair() + const keyId = '12345678' + + const sourceDir = join(tmpDir, 'source2') + const pluginDir = await mkdtemp(join(tmpdir(), 'plugins2-')) + const destDir = join(pluginDir, 'existing-plugin') + + await mkdir(sourceDir, { recursive: true }) + await mkdir(destDir, { recursive: true }) + await writeFile(join(sourceDir, 'codeburn-plugin.json'), JSON.stringify(validManifest('existing-plugin'))) + await writeFile(join(sourceDir, 'file1.txt'), 'content1') + + await signPlugin(sourceDir, privateKeyPem, keyId) + + const { RELEASE_PUBLIC_KEYS } = await import('../src/plugins/keys.js') + const originalKeys = new Map(RELEASE_PUBLIC_KEYS) + ;(RELEASE_PUBLIC_KEYS as any).set(keyId, publicKeyBase64) + + const program = new Command() + program.exitOverride() + registerPluginCommands(program) + + try { + await expect( + program.parseAsync(['node', 'codeburn', 'plugin', 'add', sourceDir, '--dir', pluginDir]), + ).rejects.toThrow() + } finally { + RELEASE_PUBLIC_KEYS.clear() + for (const [k, v] of originalKeys) { + ;(RELEASE_PUBLIC_KEYS as any).set(k, v) + } + await rm(pluginDir, { recursive: true, force: true }) + } + }) + + it('plugin remove refuses without --confirm', async () => { + const { Command } = (await import('commander')).default ?? (await import('commander')) + const { registerPluginCommands } = await import('../src/plugins/cli.js') + + const pluginDir = await mkdtemp(join(tmpdir(), 'plugins3-')) + const destDir = join(pluginDir, 'test-plugin') + await mkdir(destDir, { recursive: true }) + + const program = new Command() + program.exitOverride() + registerPluginCommands(program) + + try { + await expect( + program.parseAsync(['node', 'codeburn', 'plugin', 'remove', 'test-plugin', '--dir', pluginDir]), + ).rejects.toThrow() + } finally { + await rm(pluginDir, { recursive: true, force: true }) + } + }) + + it('plugin remove deletes with --confirm', async () => { + const { Command } = (await import('commander')).default ?? (await import('commander')) + const { registerPluginCommands } = await import('../src/plugins/cli.js') + + const pluginDir = await mkdtemp(join(tmpdir(), 'plugins4-')) + const destDir = join(pluginDir, 'test-plugin') + await mkdir(destDir, { recursive: true }) + await writeFile(join(destDir, 'test.txt'), 'test') + + const program = new Command() + program.exitOverride() + registerPluginCommands(program) + + try { + await program.parseAsync(['node', 'codeburn', 'plugin', 'remove', 'test-plugin', '--dir', pluginDir, '--confirm']) + const { stat } = await import('fs/promises') + await expect(stat(destDir)).rejects.toThrow() + } finally { + await rm(pluginDir, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/plugin-socket.test.ts b/tests/plugin-socket.test.ts index 5f6f5302..5ac767b4 100644 --- a/tests/plugin-socket.test.ts +++ b/tests/plugin-socket.test.ts @@ -138,7 +138,7 @@ describe('loader: rejection reasons', () => { expect(loads).toHaveLength(1) expect(loads[0]!.status).toBe('rejected') if (loads[0]!.status === 'rejected') { - expect(loads[0]!.reason).toMatch(/unsigned/) + expect(loads[0]!.reason).toMatch(/signature|unsigned/) } })