mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
Merge pull request #996 from mulatta/feat/external-plugin-signing-key
feat: support external plugin signing keys
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user