new plugin signing and checking
This commit is contained in:
@@ -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