new plugin signing and checking

This commit is contained in:
KingKDot
2026-04-14 09:56:20 -05:00
parent 60baac85cd
commit 59da653142
15 changed files with 1022 additions and 43 deletions
@@ -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 });
}