diff --git a/.env.example b/.env.example index b6767333b..2a758f1dd 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/lib/admin/__tests__/plugin-signing.test.ts b/lib/admin/__tests__/plugin-signing.test.ts new file mode 100644 index 000000000..20d6d1ddf --- /dev/null +++ b/lib/admin/__tests__/plugin-signing.test.ts @@ -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', + ); + }); +}); diff --git a/lib/admin/plugin-signing.ts b/lib/admin/plugin-signing.ts index cf0ff2473..977f8233b 100644 --- a/lib/admin/plugin-signing.ts +++ b/lib/admin/plugin-signing.ts @@ -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 | 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