new plugin signing and checking
This commit is contained in:
@@ -58,6 +58,9 @@ export interface Config {
|
||||
appearance: {
|
||||
customCSS: string;
|
||||
};
|
||||
plugins: {
|
||||
trustedKeys: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: Config = {
|
||||
@@ -114,6 +117,9 @@ const DEFAULT_CONFIG: Config = {
|
||||
appearance: {
|
||||
customCSS: "",
|
||||
},
|
||||
plugins: {
|
||||
trustedKeys: [],
|
||||
},
|
||||
};
|
||||
|
||||
type SaveSecrets = {
|
||||
@@ -432,6 +438,15 @@ export function loadConfig(): Config {
|
||||
appearance: {
|
||||
customCSS: fileConfig.appearance?.customCSS || DEFAULT_CONFIG.appearance.customCSS,
|
||||
},
|
||||
plugins: {
|
||||
trustedKeys: (() => {
|
||||
const envKeys = process.env.TRUSTED_PLUGIN_KEYS;
|
||||
if (envKeys) {
|
||||
return envKeys.split(",").map((k) => k.trim()).filter(Boolean);
|
||||
}
|
||||
return fileConfig.plugins?.trustedKeys || DEFAULT_CONFIG.plugins.trustedKeys;
|
||||
})(),
|
||||
},
|
||||
};
|
||||
|
||||
if (saveChanged) {
|
||||
@@ -616,6 +631,31 @@ export async function updateAppearanceConfig(customCSS: string): Promise<Config[
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function updatePluginsConfig(
|
||||
updates: Partial<Config["plugins"]>,
|
||||
): Promise<Config["plugins"]> {
|
||||
const current = getConfig();
|
||||
const trustedKeys = (updates.trustedKeys || current.plugins.trustedKeys || [])
|
||||
.map((k) => String(k).trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const deduped = Array.from(new Set(trustedKeys));
|
||||
|
||||
const next: Config["plugins"] = {
|
||||
...current.plugins,
|
||||
trustedKeys: deduped,
|
||||
};
|
||||
|
||||
configCache = {
|
||||
...current,
|
||||
plugins: next,
|
||||
};
|
||||
|
||||
const fileConfig = readFileConfigForUpdate();
|
||||
fileConfig.plugins = next;
|
||||
await writePersistentFileConfig(fileConfig);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function getExportableConfig(serverVersion: string): Record<string, unknown> {
|
||||
const config = getConfig();
|
||||
return {
|
||||
@@ -632,6 +672,7 @@ export function getExportableConfig(serverVersion: string): Record<string, unkno
|
||||
tls: config.tls,
|
||||
enrollment: config.enrollment,
|
||||
appearance: config.appearance,
|
||||
plugins: config.plugins,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -685,6 +726,11 @@ export async function importFullConfig(data: Record<string, any>): Promise<{ app
|
||||
}
|
||||
}
|
||||
|
||||
if (data.plugins && typeof data.plugins === "object") {
|
||||
await updatePluginsConfig(data.plugins);
|
||||
applied.push("plugins");
|
||||
}
|
||||
|
||||
if (data.auth && typeof data.auth === "object") {
|
||||
const dataDir = ensureDataDir();
|
||||
const savePath = resolve(dataDir, "save.json");
|
||||
|
||||
@@ -270,6 +270,14 @@ export type PluginManifest = {
|
||||
};
|
||||
};
|
||||
|
||||
export type PluginSignatureInfo = {
|
||||
signed: boolean;
|
||||
trusted: boolean;
|
||||
valid: boolean;
|
||||
fingerprint?: string;
|
||||
algorithm?: string;
|
||||
};
|
||||
|
||||
export type PluginEvent = {
|
||||
type: "plugin_event";
|
||||
pluginId: string;
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import AdmZip from "adm-zip";
|
||||
import type { PluginSignatureInfo } from "../protocol";
|
||||
import { logger } from "../logger";
|
||||
import { getConfig } from "../config";
|
||||
|
||||
export const BUILTIN_TRUSTED_KEYS: string[] = [
|
||||
"c0f7b94f9678425d08d2408fd92494c419a5ea6819ee4dbcebc41d86c22c61fa",
|
||||
];
|
||||
|
||||
export async function computeContentDigest(zip: InstanceType<typeof AdmZip>): Promise<string> {
|
||||
const entries = zip.getEntries();
|
||||
const fileHashes: Array<{ name: string; hash: string }> = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory) continue;
|
||||
if (entry.entryName === "signature.json") continue;
|
||||
|
||||
const data = entry.getData();
|
||||
const hashBuf = await crypto.subtle.digest("SHA-256", data);
|
||||
const hash = Buffer.from(hashBuf).toString("hex");
|
||||
fileHashes.push({ name: entry.entryName, hash });
|
||||
}
|
||||
|
||||
fileHashes.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return fileHashes.map((f) => `${f.name}:${f.hash}\n`).join("");
|
||||
}
|
||||
|
||||
export function extractSignatureFromZip(
|
||||
zip: InstanceType<typeof AdmZip>,
|
||||
): { algorithm: string; publicKey: string; signature: string } | null {
|
||||
const entry = zip.getEntry("signature.json");
|
||||
if (!entry) return null;
|
||||
|
||||
try {
|
||||
const raw = entry.getData().toString("utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (
|
||||
typeof parsed.algorithm === "string" &&
|
||||
typeof parsed.publicKey === "string" &&
|
||||
typeof parsed.signature === "string"
|
||||
) {
|
||||
return {
|
||||
algorithm: parsed.algorithm,
|
||||
publicKey: parsed.publicKey,
|
||||
signature: parsed.signature,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function computeKeyFingerprint(publicKeyBase64: string): Promise<string> {
|
||||
const pubBytes = Buffer.from(publicKeyBase64, "base64");
|
||||
const hashBuf = await crypto.subtle.digest("SHA-256", pubBytes);
|
||||
return Buffer.from(hashBuf).toString("hex");
|
||||
}
|
||||
|
||||
export function isKeyTrusted(fingerprint: string, trustedKeys: string[]): boolean {
|
||||
const normalized = fingerprint.toLowerCase();
|
||||
const allTrusted = [...BUILTIN_TRUSTED_KEYS, ...trustedKeys];
|
||||
return allTrusted.some((k) => k.toLowerCase() === normalized);
|
||||
}
|
||||
|
||||
export async function verifyPluginSignature(zipPath: string): Promise<PluginSignatureInfo> {
|
||||
let zip: InstanceType<typeof AdmZip>;
|
||||
try {
|
||||
zip = new AdmZip(zipPath);
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin-signature] failed to read ZIP ${zipPath}: ${(err as Error).message}`);
|
||||
return { signed: false, trusted: false, valid: false };
|
||||
}
|
||||
|
||||
const sigData = extractSignatureFromZip(zip);
|
||||
if (!sigData) {
|
||||
return { signed: false, trusted: false, valid: false };
|
||||
}
|
||||
|
||||
if (sigData.algorithm !== "ed25519") {
|
||||
logger.warn(`[plugin-signature] unsupported algorithm: ${sigData.algorithm}`);
|
||||
return { signed: true, trusted: false, valid: false, algorithm: sigData.algorithm };
|
||||
}
|
||||
|
||||
try {
|
||||
const pubBytes = Buffer.from(sigData.publicKey, "base64");
|
||||
if (pubBytes.length !== 32) {
|
||||
return { signed: true, trusted: false, valid: false, algorithm: sigData.algorithm };
|
||||
}
|
||||
|
||||
const publicKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
pubBytes,
|
||||
{ name: "Ed25519" },
|
||||
false,
|
||||
["verify"],
|
||||
);
|
||||
|
||||
const digest = await computeContentDigest(zip);
|
||||
const digestBytes = new TextEncoder().encode(digest);
|
||||
|
||||
const sigBytes = Buffer.from(sigData.signature, "base64");
|
||||
if (sigBytes.length !== 64) {
|
||||
return { signed: true, trusted: false, valid: false, algorithm: sigData.algorithm };
|
||||
}
|
||||
|
||||
const valid = await crypto.subtle.verify("Ed25519", publicKey, sigBytes, digestBytes);
|
||||
|
||||
const fingerprint = await computeKeyFingerprint(sigData.publicKey);
|
||||
|
||||
const config = getConfig();
|
||||
const trusted = valid && isKeyTrusted(fingerprint, config.plugins.trustedKeys);
|
||||
|
||||
return {
|
||||
signed: true,
|
||||
valid,
|
||||
trusted,
|
||||
fingerprint,
|
||||
algorithm: sigData.algorithm,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin-signature] verification error: ${(err as Error).message}`);
|
||||
return { signed: true, trusted: false, valid: false, algorithm: sigData.algorithm };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrVerifySignature(
|
||||
pluginRoot: string,
|
||||
pluginId: string,
|
||||
): Promise<PluginSignatureInfo> {
|
||||
const sigInfoPath = path.join(pluginRoot, pluginId, "signature-info.json");
|
||||
const zipPath = path.join(pluginRoot, `${pluginId}.zip`);
|
||||
|
||||
try {
|
||||
const [zipStat, sigInfoStat] = await Promise.all([
|
||||
fs.stat(zipPath),
|
||||
fs.stat(sigInfoPath),
|
||||
]);
|
||||
if (sigInfoStat.mtimeMs >= zipStat.mtimeMs) {
|
||||
const raw = await fs.readFile(sigInfoPath, "utf-8");
|
||||
const cached = JSON.parse(raw) as PluginSignatureInfo;
|
||||
if (cached.signed && cached.valid && cached.fingerprint) {
|
||||
const config = getConfig();
|
||||
cached.trusted = isKeyTrusted(cached.fingerprint, config.plugins.trustedKeys);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.stat(zipPath);
|
||||
} catch {
|
||||
return { signed: false, trusted: false, valid: false };
|
||||
}
|
||||
|
||||
const info = await verifyPluginSignature(zipPath);
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.join(pluginRoot, pluginId), { recursive: true });
|
||||
await fs.writeFile(sigInfoPath, JSON.stringify(info, null, 2));
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin-signature] failed to cache signature info: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import AdmZip from "adm-zip";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { ClientInfo } from "../types";
|
||||
import { logger } from "../logger";
|
||||
import { encodeMessage, type PluginManifest } from "../protocol";
|
||||
import { encodeMessage, type PluginManifest, type PluginSignatureInfo } from "../protocol";
|
||||
import { verifyPluginSignature, getOrVerifySignature } from "./plugin-signature";
|
||||
|
||||
export type PluginState = {
|
||||
enabled: Record<string, boolean>;
|
||||
@@ -126,6 +127,14 @@ export async function ensurePluginExtracted(
|
||||
},
|
||||
};
|
||||
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
|
||||
try {
|
||||
const sigInfo = await verifyPluginSignature(zipPath);
|
||||
const sigInfoPath = path.join(pluginDir, "signature-info.json");
|
||||
await fs.writeFile(sigInfoPath, JSON.stringify(sigInfo, null, 2));
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin] failed to verify signature for ${safeId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncPluginBundles(
|
||||
@@ -146,16 +155,20 @@ export async function syncPluginBundles(
|
||||
}
|
||||
}
|
||||
|
||||
export type PluginManifestWithSignature = PluginManifest & {
|
||||
signature?: PluginSignatureInfo;
|
||||
};
|
||||
|
||||
export async function listPluginManifests(
|
||||
pluginRoot: string,
|
||||
pluginState: PluginState,
|
||||
saveState: () => Promise<void>,
|
||||
ensureExtracted: (pluginId: string) => Promise<void>,
|
||||
): Promise<PluginManifest[]> {
|
||||
): Promise<PluginManifestWithSignature[]> {
|
||||
try {
|
||||
await syncPluginBundles(pluginRoot, ensureExtracted);
|
||||
const entries = await fs.readdir(pluginRoot, { withFileTypes: true });
|
||||
const manifests: PluginManifest[] = [];
|
||||
const manifests: PluginManifestWithSignature[] = [];
|
||||
for (const ent of entries) {
|
||||
if (!ent.isDirectory()) continue;
|
||||
const manifestPath = path.join(pluginRoot, ent.name, "manifest.json");
|
||||
@@ -167,7 +180,8 @@ export async function listPluginManifests(
|
||||
if (pluginState.enabled[id] === undefined) {
|
||||
pluginState.enabled[id] = true;
|
||||
}
|
||||
manifests.push({ ...manifest, id, name });
|
||||
const sigInfo = await getOrVerifySignature(pluginRoot, id);
|
||||
manifests.push({ ...manifest, id, name, signature: sigInfo });
|
||||
} catch {}
|
||||
}
|
||||
await saveState();
|
||||
|
||||
@@ -6,11 +6,14 @@ import { requirePermission } from "../../rbac";
|
||||
import { canUserAccessClient } from "../../users";
|
||||
import * as clientManager from "../../clientManager";
|
||||
import { metrics } from "../../metrics";
|
||||
import { encodeMessage } from "../../protocol";
|
||||
import { encodeMessage, type PluginSignatureInfo } from "../../protocol";
|
||||
import { getConfig, updatePluginsConfig } from "../../config";
|
||||
import { getOrVerifySignature, BUILTIN_TRUSTED_KEYS } from "../plugin-signature";
|
||||
|
||||
type PluginManifest = {
|
||||
id: string;
|
||||
name: string;
|
||||
signature?: PluginSignatureInfo;
|
||||
};
|
||||
|
||||
type PluginBundle = {
|
||||
@@ -71,10 +74,57 @@ export async function handlePluginRoutes(
|
||||
lastError: deps.pluginState.lastError[p.id] || "",
|
||||
autoLoad: deps.pluginState.autoLoad[p.id] === true,
|
||||
autoStartEvents: deps.pluginState.autoStartEvents[p.id] || [],
|
||||
signature: p.signature || { signed: false, trusted: false, valid: false },
|
||||
}));
|
||||
return Response.json({ plugins: enriched });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/plugins/trusted-keys") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
if (user.role !== "admin") {
|
||||
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||
}
|
||||
const config = getConfig();
|
||||
const allKeys = Array.from(new Set([...BUILTIN_TRUSTED_KEYS, ...config.plugins.trustedKeys]));
|
||||
return Response.json({ trustedKeys: allKeys, builtinKeys: BUILTIN_TRUSTED_KEYS });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/plugins/trusted-keys") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
if (user.role !== "admin") {
|
||||
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||
}
|
||||
let body: any = {};
|
||||
try { body = await req.json(); } catch {}
|
||||
const fingerprint = typeof body.fingerprint === "string" ? body.fingerprint.trim().toLowerCase() : "";
|
||||
if (!fingerprint || !/^[a-f0-9]{64}$/.test(fingerprint)) {
|
||||
return Response.json({ ok: false, error: "Invalid fingerprint (expected 64-char hex SHA-256)" }, { status: 400 });
|
||||
}
|
||||
const config = getConfig();
|
||||
const keys = [...config.plugins.trustedKeys];
|
||||
if (!keys.includes(fingerprint)) {
|
||||
keys.push(fingerprint);
|
||||
await updatePluginsConfig({ trustedKeys: keys });
|
||||
}
|
||||
return Response.json({ ok: true, trustedKeys: keys });
|
||||
}
|
||||
|
||||
const trustedKeyDeleteMatch = url.pathname.match(/^\/api\/plugins\/trusted-keys\/([a-f0-9]{64})$/);
|
||||
if (req.method === "DELETE" && trustedKeyDeleteMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
if (user.role !== "admin") {
|
||||
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||
}
|
||||
const fingerprint = trustedKeyDeleteMatch[1].toLowerCase();
|
||||
const config = getConfig();
|
||||
const keys = config.plugins.trustedKeys.filter((k) => k.toLowerCase() !== fingerprint);
|
||||
await updatePluginsConfig({ trustedKeys: keys });
|
||||
return Response.json({ ok: true, trustedKeys: keys });
|
||||
}
|
||||
|
||||
const clientPluginsMatch = url.pathname.match(/^\/api\/clients\/(.+)\/plugins$/);
|
||||
if (req.method === "GET" && clientPluginsMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
@@ -100,6 +150,7 @@ export async function handlePluginRoutes(
|
||||
loaded: loaded.has(manifest.id),
|
||||
enabled: deps.pluginState.enabled[manifest.id] !== false,
|
||||
lastError: deps.pluginState.lastError[manifest.id] || "",
|
||||
signature: manifest.signature || { signed: false, trusted: false, valid: false },
|
||||
}));
|
||||
return Response.json({ plugins });
|
||||
}
|
||||
@@ -149,12 +200,14 @@ export async function handlePluginRoutes(
|
||||
return Response.json({ ok: false, error: (err as Error).message }, { status: 400 });
|
||||
}
|
||||
|
||||
const sigInfo = await getOrVerifySignature(deps.PLUGIN_ROOT, pluginId);
|
||||
|
||||
if (deps.pluginState.enabled[pluginId] === undefined) {
|
||||
deps.pluginState.enabled[pluginId] = true;
|
||||
deps.pluginState.enabled[pluginId] = sigInfo.trusted === true;
|
||||
await deps.savePluginState();
|
||||
}
|
||||
|
||||
return Response.json({ ok: true, id: pluginId });
|
||||
return Response.json({ ok: true, id: pluginId, enabled: deps.pluginState.enabled[pluginId], signature: sigInfo });
|
||||
}
|
||||
|
||||
const pluginEnableMatch = url.pathname.match(/^\/api\/plugins\/(.+)\/enable$/);
|
||||
@@ -177,6 +230,19 @@ export async function handlePluginRoutes(
|
||||
body = await req.json();
|
||||
} catch {}
|
||||
const enabled = !!body.enabled;
|
||||
|
||||
if (enabled) {
|
||||
const sigInfo = await getOrVerifySignature(deps.PLUGIN_ROOT, pluginId);
|
||||
if (!sigInfo.trusted) {
|
||||
if (body.confirmed !== true) {
|
||||
return Response.json(
|
||||
{ ok: false, error: "confirmation_required", signature: sigInfo },
|
||||
{ status: 428 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deps.pluginState.enabled[pluginId] = enabled;
|
||||
await deps.savePluginState();
|
||||
return Response.json({ ok: true, id: pluginId, enabled });
|
||||
@@ -202,6 +268,14 @@ export async function handlePluginRoutes(
|
||||
body = await req.json();
|
||||
} catch {}
|
||||
const autoLoad = !!body.autoLoad;
|
||||
|
||||
if (autoLoad && deps.pluginState.enabled[pluginId] === false) {
|
||||
return Response.json(
|
||||
{ ok: false, error: "Plugin must be enabled before auto-load can be turned on" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
deps.pluginState.autoLoad[pluginId] = autoLoad;
|
||||
|
||||
if (Array.isArray(body.autoStartEvents)) {
|
||||
@@ -474,12 +548,35 @@ export async function handlePluginRoutes(
|
||||
if (deps.isPluginLoading(targetId, pluginId)) {
|
||||
return Response.json({ ok: true, loading: true });
|
||||
}
|
||||
|
||||
const sigInfo = await getOrVerifySignature(deps.PLUGIN_ROOT, pluginId);
|
||||
|
||||
if (sigInfo.signed && !sigInfo.valid) {
|
||||
return Response.json(
|
||||
{ ok: false, error: "Plugin signature is invalid — the plugin may have been tampered with", signature: sigInfo },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!sigInfo.trusted) {
|
||||
let body: any = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {}
|
||||
if (body.confirmed !== true) {
|
||||
return Response.json(
|
||||
{ ok: false, error: "confirmation_required", signature: sigInfo },
|
||||
{ status: 428 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const bundle = await deps.loadPluginBundle(pluginId, target.os, target.arch);
|
||||
deps.markPluginLoading(targetId, pluginId);
|
||||
deps.sendPluginBundle(target, bundle);
|
||||
metrics.recordCommand("plugin_load");
|
||||
return Response.json({ ok: true });
|
||||
return Response.json({ ok: true, signature: sigInfo });
|
||||
} catch (err) {
|
||||
return Response.json({ ok: false, error: (err as Error).message }, { status: 400 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user