Merge pull request #996 from mulatta/feat/external-plugin-signing-key

feat: support external plugin signing keys
This commit is contained in:
Linus Rath
2026-09-30 14:23:45 +02:00
3 changed files with 151 additions and 10 deletions
+6
View File
@@ -150,6 +150,12 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# error than a mid-request EROFS). Pair with `:ro` on the config-volume mount.
# ADMIN_CONFIG_READONLY=true
#
# Optional existing PEM PKCS#8 Ed25519 private key used to sign managed plugin
# bundles. Bulwark reads this exact path without modifying it. Use a runtime
# secret mount such as a systemd credential, Docker secret, or Kubernetes Secret.
# When unset, Bulwark loads or creates ADMIN_CONFIG_DIR/plugin-signing.key.
# PLUGIN_SIGNING_KEY_FILE=/run/secrets/plugin-signing-key
#
# Legacy: a single dir containing both config and state. Honoured if neither
# of the split variables is set. New installs should use the split vars.
# ADMIN_DATA_DIR=./data/admin
+112
View File
@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createPublicKey, generateKeyPairSync, verify } from 'node:crypto';
import { access, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { tmpdir } from 'node:os';
import {
getPublicKeyBase64,
invalidatePluginSigningCache,
signBytes,
} from '../plugin-signing';
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), 'bw-plugin-signing-'));
invalidatePluginSigningCache();
});
afterEach(async () => {
invalidatePluginSigningCache();
vi.unstubAllEnvs();
await rm(dir, { recursive: true, force: true });
});
function privateKeyPem(type: 'ed25519' | 'rsa' = 'ed25519'): string {
const privateKey = type === 'rsa'
? generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey
: generateKeyPairSync('ed25519').privateKey;
return privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
}
describe('plugin signing key storage', () => {
it('loads an external Ed25519 PKCS#8 key without touching it or the config directory', async () => {
const keyPath = path.join(dir, 'secrets', 'plugin-signing.pem');
const configDir = path.join(dir, 'immutable-config');
await mkdir(path.dirname(keyPath));
const pem = privateKeyPem();
await writeFile(keyPath, pem, { mode: 0o400 });
const before = await stat(keyPath);
vi.stubEnv('PLUGIN_SIGNING_KEY_FILE', keyPath);
vi.stubEnv('ADMIN_CONFIG_DIR', configDir);
vi.stubEnv('ADMIN_CONFIG_READONLY', 'true');
const payload = Buffer.from('external signing key');
const signature = Buffer.from(await signBytes(payload), 'base64');
const publicKey = createPublicKey(pem);
expect(verify(null, payload, publicKey, signature)).toBe(true);
expect(await readFile(keyPath, 'utf8')).toBe(pem);
const after = await stat(keyPath);
expect(after.mode).toBe(before.mode);
expect(after.mtimeMs).toBe(before.mtimeMs);
await expect(access(configDir)).rejects.toMatchObject({ code: 'ENOENT' });
});
it('keeps loading an existing key from ADMIN_CONFIG_DIR when external path is unset', async () => {
const configDir = path.join(dir, 'config');
const pem = privateKeyPem();
await mkdir(configDir);
await writeFile(path.join(configDir, 'plugin-signing.key'), pem);
vi.stubEnv('ADMIN_CONFIG_DIR', configDir);
const expected = createPublicKey(pem).export({ type: 'spki', format: 'der' }) as Buffer;
await expect(getPublicKeyBase64()).resolves.toBe(expected.subarray(-32).toString('base64'));
});
it('keeps generating plugin-signing.key when external path is unset', async () => {
const configDir = path.join(dir, 'config');
vi.stubEnv('ADMIN_CONFIG_DIR', configDir);
await expect(signBytes('generated key')).resolves.toMatch(/^[A-Za-z0-9+/]{86}==$/);
const generated = await readFile(path.join(configDir, 'plugin-signing.key'), 'utf8');
expect(generated).toContain('-----BEGIN PRIVATE KEY-----');
});
it('reports a missing external key without falling back to generation', async () => {
const keyPath = path.join(dir, 'missing.pem');
const configDir = path.join(dir, 'config');
vi.stubEnv('PLUGIN_SIGNING_KEY_FILE', keyPath);
vi.stubEnv('ADMIN_CONFIG_DIR', configDir);
await expect(signBytes('payload')).rejects.toThrow(
'Cannot read plugin signing key from PLUGIN_SIGNING_KEY_FILE',
);
await expect(access(configDir)).rejects.toMatchObject({ code: 'ENOENT' });
});
it('reports malformed external key data without exposing its contents', async () => {
const keyPath = path.join(dir, 'malformed.pem');
const secret = 'not-a-private-key-secret';
await writeFile(keyPath, secret);
vi.stubEnv('PLUGIN_SIGNING_KEY_FILE', keyPath);
const error = await signBytes('payload').then(
() => null,
(caught: unknown) => caught as Error,
);
expect(error).not.toBeNull();
expect(error!.message).toContain('valid PEM PKCS#8 private key');
expect(error!.message).not.toContain(secret);
});
it('reports an external private key of the wrong asymmetric type', async () => {
const keyPath = path.join(dir, 'rsa.pem');
await writeFile(keyPath, privateKeyPem('rsa'));
vi.stubEnv('PLUGIN_SIGNING_KEY_FILE', keyPath);
await expect(signBytes('payload')).rejects.toThrow(
'PLUGIN_SIGNING_KEY_FILE has wrong key type (rsa); expected ed25519',
);
});
});
+33 -10
View File
@@ -5,10 +5,10 @@
// attacker swaps the bundle bytes in transit or at rest, the client refuses
// to load anything that doesn't verify against the host's public key.
//
// The keypair lives at `data/admin/plugin-signing.key` (PEM-encoded
// PKCS#8 private, mode 0600) and is generated lazily on first use. Operators
// who want to pin the key out-of-band can drop a pre-generated PEM at that
// path before first boot - the loader just reads what's there.
// By default, the keypair lives at `data/admin/plugin-signing.key`
// (PEM-encoded PKCS#8 private, mode 0600) and is generated lazily on first
// use. Operators can instead point PLUGIN_SIGNING_KEY_FILE at a read-only
// secret outside the admin config directory.
import { generateKeyPairSync, createPrivateKey, createPublicKey, sign as nodeSign, KeyObject } from 'node:crypto';
import { readFile, writeFile, chmod } from 'node:fs/promises';
@@ -21,18 +21,41 @@ const KEY_FILENAME = 'plugin-signing.key';
let cached: { privateKey: KeyObject; publicKey: KeyObject } | null = null;
let initPromise: Promise<void> | null = null;
function parseKey(pem: string, source: string): { privateKey: KeyObject; publicKey: KeyObject } {
let privateKey: KeyObject;
try {
if (!pem.includes('-----BEGIN PRIVATE KEY-----')) throw new Error('not PKCS#8 PEM');
privateKey = createPrivateKey({ key: pem, format: 'pem', type: 'pkcs8' });
} catch {
throw new Error(`${source} is not a valid PEM PKCS#8 private key`);
}
if (privateKey.asymmetricKeyType !== 'ed25519') {
throw new Error(`${source} has wrong key type (${privateKey.asymmetricKeyType}); expected ed25519`);
}
return { privateKey, publicKey: createPublicKey(privateKey) };
}
async function loadExternal(path: string): Promise<{ privateKey: KeyObject; publicKey: KeyObject }> {
let pem: string;
try {
pem = await readFile(path, 'utf-8');
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new Error(`Cannot read plugin signing key from PLUGIN_SIGNING_KEY_FILE: ${detail}`);
}
return parseKey(pem, 'PLUGIN_SIGNING_KEY_FILE');
}
async function loadOrCreate(): Promise<{ privateKey: KeyObject; publicKey: KeyObject }> {
const externalPath = process.env.PLUGIN_SIGNING_KEY_FILE;
if (externalPath !== undefined) return loadExternal(externalPath);
await ensureConfigDir();
const path = getConfigPath(KEY_FILENAME);
if (existsSync(path)) {
const pem = await readFile(path, 'utf-8');
const privateKey = createPrivateKey({ key: pem, format: 'pem' });
if (privateKey.asymmetricKeyType !== 'ed25519') {
throw new Error(`plugin-signing.key has wrong key type (${privateKey.asymmetricKeyType}); expected ed25519`);
}
const publicKey = createPublicKey(privateKey);
return { privateKey, publicKey };
return parseKey(pem, KEY_FILENAME);
}
// First boot: generate and persist. Use sync APIs so a half-written file