mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-30 10:52:56 +00:00
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 <path>: installs signed plugins to ~/.config/codeburn/plugins/ - plugin remove <name> --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
This commit is contained in:
parent
5a72bb35c0
commit
67320432ca
7 changed files with 679 additions and 6 deletions
136
scripts/sign-plugin.mjs
Normal file
136
scripts/sign-plugin.mjs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Plugin signing utility: keygen and sign.
|
||||
*
|
||||
* keygen --out <file> - generate a keypair, print keyId + public key, write private PEM
|
||||
* sign <dir> - 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 <file>')
|
||||
console.error(' node scripts/sign-plugin.mjs sign <dir>')
|
||||
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 <file>')
|
||||
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 <dir>')
|
||||
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')
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 <path>')
|
||||
.description('Install a plugin from a source directory')
|
||||
.option('--dir <path>', '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 <name>')
|
||||
.description('Remove an installed plugin')
|
||||
.option('--dir <path>', '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 <dir>/codeburn-plugin.json and parses it via the
|
||||
|
|
|
|||
6
src/plugins/keys.ts
Normal file
6
src/plugins/keys.ts
Normal file
|
|
@ -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<string, string> = new Map([
|
||||
['f40248d0', 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUNvd0JRWURLMlZ3QXlFQTU1QTAvMUpLTlBoMGFsL2xMN2xhWFBobWVnZVhzUENqK3RoM1B4LzNWM0U9Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo='],
|
||||
])
|
||||
|
|
@ -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<string, string>,
|
||||
): 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<boolean> {
|
||||
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<Array<{ path: string, sha256: string }>> {
|
||||
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 }> {
|
||||
|
|
|
|||
335
tests/plugin-signing.test.ts
Normal file
335
tests/plugin-signing.test.ts
Normal file
|
|
@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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/)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue