From 59da65314269754806e4e4c819017fdff8349c18 Mon Sep 17 00:00:00 2001 From: KingKDot Date: Tue, 14 Apr 2026 09:56:20 -0500 Subject: [PATCH] new plugin signing and checking --- Overlord-Server/config.json.example | 3 + Overlord-Server/plugin-signing.pub | 2 + Overlord-Server/public/assets/main.js | 132 +++++++- Overlord-Server/public/assets/plugins.js | 281 ++++++++++++++++-- Overlord-Server/public/plugins.html | 21 ++ Overlord-Server/scripts/plugin-keygen.ts | 51 ++++ Overlord-Server/scripts/plugin-sign.ts | 111 +++++++ Overlord-Server/src/config.ts | 46 +++ Overlord-Server/src/protocol.ts | 8 + .../src/server/plugin-signature.ts | 169 +++++++++++ .../src/server/plugin-state-bundle.ts | 22 +- .../src/server/routes/plugin-routes.ts | 105 ++++++- PLUGINS.md | 92 ++++++ build-plugin.bat | 11 + build-plugin.sh | 11 + 15 files changed, 1022 insertions(+), 43 deletions(-) create mode 100644 Overlord-Server/plugin-signing.pub create mode 100644 Overlord-Server/scripts/plugin-keygen.ts create mode 100644 Overlord-Server/scripts/plugin-sign.ts create mode 100644 Overlord-Server/src/server/plugin-signature.ts diff --git a/Overlord-Server/config.json.example b/Overlord-Server/config.json.example index 1ea3388..131f406 100644 --- a/Overlord-Server/config.json.example +++ b/Overlord-Server/config.json.example @@ -35,5 +35,8 @@ }, "enrollment": { "requireApproval": true + }, + "plugins": { + "trustedKeys": [] } } diff --git a/Overlord-Server/plugin-signing.pub b/Overlord-Server/plugin-signing.pub new file mode 100644 index 0000000..9c86da0 --- /dev/null +++ b/Overlord-Server/plugin-signing.pub @@ -0,0 +1,2 @@ +IqAQbR5RB+YOhFg3HILNtqqULAU0Z4YAbSEZXDNvDv4= +# fingerprint: c0f7b94f9678425d08d2408fd92494c419a5ea6819ee4dbcebc41d86c22c61fa diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js index d40d420..2c75bf2 100644 --- a/Overlord-Server/public/assets/main.js +++ b/Overlord-Server/public/assets/main.js @@ -309,6 +309,89 @@ async function loadCurrentUser() { } } +function showPluginConfirmModal(pluginId, clientId, sigInfo) { + document.getElementById("plugin-confirm-modal")?.remove(); + + const isSigned = sigInfo && sigInfo.signed && sigInfo.valid; + const statusText = isSigned + ? `This plugin is signed but the signer's key is not trusted.` + : `This plugin is not signed and its origin cannot be verified.`; + const fpText = sigInfo?.fingerprint + ? `Signer fingerprint: ${sigInfo.fingerprint}` + : "No signature present"; + + const modal = document.createElement("div"); + modal.id = "plugin-confirm-modal"; + modal.className = "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"; + modal.innerHTML = ` +
+
+
+ +
+
+

${isSigned ? 'Untrusted Plugin' : 'Unsigned Plugin'}

+

${pluginId}

+
+
+

${statusText}

+

${fpText}

+

Type confirm below to load this plugin:

+ +
+ + +
+
+ `; + document.body.appendChild(modal); + + const input = document.getElementById("plugin-confirm-input"); + const loadBtn = document.getElementById("plugin-confirm-load"); + const cancelBtn = document.getElementById("plugin-confirm-cancel"); + + input.addEventListener("input", () => { + const match = input.value.trim().toLowerCase() === "confirm"; + loadBtn.disabled = !match; + loadBtn.classList.toggle("opacity-50", !match); + loadBtn.classList.toggle("cursor-not-allowed", !match); + loadBtn.classList.toggle("hover:bg-emerald-800/60", match); + }); + + cancelBtn.addEventListener("click", () => modal.remove()); + modal.addEventListener("click", (e) => { if (e.target === modal) modal.remove(); }); + + loadBtn.addEventListener("click", async () => { + if (loadBtn.disabled) return; + loadBtn.disabled = true; + loadBtn.textContent = "Loading…"; + try { + const res = await fetch(`/api/clients/${clientId}/plugins/${pluginId}/load`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ confirmed: true }), + }); + if (!res.ok) { + const text = await res.text(); + alert(`Plugin load failed: ${text}`); + } else { + window.open(`/plugins/${pluginId}?clientId=${clientId}`, "_blank", "noopener"); + } + } catch { + alert("Plugin load failed"); + } + modal.remove(); + }); + + input.focus(); +} + async function loadPluginsForClient(clientId) { const section = document.getElementById("plugin-section"); const container = document.getElementById("plugin-menu"); @@ -336,11 +419,38 @@ async function loadPluginsForClient(clientId) { if (plugin.lastError) { btn.title = `Last error: ${plugin.lastError}`; } + + const sig = plugin.signature; + if (sig && sig.signed && !sig.valid) { + btn.disabled = true; + btn.classList.add("opacity-50", "cursor-not-allowed"); + btn.title = "Plugin signature is invalid — cannot load"; + } + const label = document.createElement("span"); + label.className = "flex items-center gap-1"; const labelIcon = document.createElement("i"); labelIcon.className = "fa-solid fa-puzzle-piece"; label.appendChild(labelIcon); label.append(` ${plugin.name || plugin.id}`); + + if (sig) { + const trustIcon = document.createElement("i"); + if (sig.signed && !sig.valid) { + trustIcon.className = "fa-solid fa-shield-xmark text-red-400 text-xs ml-1"; + trustIcon.title = "Invalid signature"; + } else if (sig.signed && sig.valid && sig.trusted) { + trustIcon.className = "fa-solid fa-shield-check text-emerald-400 text-xs ml-1"; + trustIcon.title = "Trusted"; + } else if (sig.signed && sig.valid && !sig.trusted) { + trustIcon.className = "fa-solid fa-shield text-yellow-400 text-xs ml-1"; + trustIcon.title = "Signed but untrusted"; + } else { + trustIcon.className = "fa-solid fa-shield-halved text-orange-400 text-xs ml-1"; + trustIcon.title = "Unsigned"; + } + label.appendChild(trustIcon); + } const badge = document.createElement("span"); badge.className = "text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full border" + @@ -777,17 +887,35 @@ menu.addEventListener("click", async (e) => { } const pluginId = target.dataset.plugin; if (pluginId) { + const savedClientId = contextCard; try { - const res = await fetch(`/api/clients/${contextCard}/plugins/${pluginId}/load`, { + const res = await fetch(`/api/clients/${savedClientId}/plugins/${pluginId}/load`, { method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), }); + + if (res.status === 428) { + const data = await res.json(); + closeMenu(clearContext); + showPluginConfirmModal(pluginId, savedClientId, data.signature); + return; + } + + if (res.status === 403) { + const data = await res.json(); + alert(data.error || "Plugin load blocked — invalid signature"); + closeMenu(clearContext); + return; + } + if (!res.ok) { const text = await res.text(); alert(`Plugin load failed: ${text}`); closeMenu(clearContext); return; } - window.open(`/plugins/${pluginId}?clientId=${contextCard}`, "_blank", "noopener"); + window.open(`/plugins/${pluginId}?clientId=${savedClientId}`, "_blank", "noopener"); } catch (err) { alert("Plugin load failed"); } diff --git a/Overlord-Server/public/assets/plugins.js b/Overlord-Server/public/assets/plugins.js index f4b21df..f90c1bf 100644 --- a/Overlord-Server/public/assets/plugins.js +++ b/Overlord-Server/public/assets/plugins.js @@ -88,6 +88,14 @@ async function fetchPlugins() { return Array.isArray(data.plugins) ? data.plugins : []; } +function getTrustBadge(sig) { + if (!sig) return { icon: "fa-shield-halved", color: "text-orange-400 border-orange-600 bg-orange-900/30", label: "Unsigned", tooltip: "This plugin is not signed" }; + if (sig.signed && !sig.valid) return { icon: "fa-shield-xmark", color: "text-red-400 border-red-600 bg-red-900/30", label: "Invalid", tooltip: "Signature verification failed — plugin may be tampered" }; + if (sig.signed && sig.valid && sig.trusted) return { icon: "fa-shield-check", color: "text-emerald-400 border-emerald-600 bg-emerald-900/30", label: "Trusted", tooltip: `Signed by trusted key: ${sig.fingerprint || "unknown"}` }; + if (sig.signed && sig.valid && !sig.trusted) return { icon: "fa-shield", color: "text-yellow-400 border-yellow-600 bg-yellow-900/30", label: "Untrusted", tooltip: `Signed but key not trusted: ${sig.fingerprint || "unknown"}` }; + return { icon: "fa-shield-halved", color: "text-orange-400 border-orange-600 bg-orange-900/30", label: "Unsigned", tooltip: "This plugin is not signed" }; +} + function renderPlugins(plugins) { pluginList.innerHTML = ""; if (!plugins.length) { @@ -100,13 +108,34 @@ function renderPlugins(plugins) { card.className = "rounded-xl border border-slate-800 bg-slate-950/50 px-4 py-3 flex items-center justify-between"; const meta = document.createElement("div"); - const title = document.createElement("div"); + const titleRow = document.createElement("div"); + titleRow.className = "flex items-center gap-2"; + const title = document.createElement("span"); title.className = "font-semibold"; title.textContent = plugin.name || plugin.id; + titleRow.appendChild(title); + + // Trust badge + const badge = getTrustBadge(plugin.signature); + const trustBadge = document.createElement("span"); + trustBadge.className = `inline-flex items-center gap-1 text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full border ${badge.color}`; + trustBadge.innerHTML = ` ${badge.label}`; + trustBadge.title = badge.tooltip; + titleRow.appendChild(trustBadge); + const subtitle = document.createElement("div"); subtitle.className = "text-sm text-slate-400"; subtitle.textContent = `${plugin.id}${plugin.version ? ` • v${plugin.version}` : ""}`; - meta.appendChild(title); + + if (plugin.signature?.fingerprint) { + const fpSpan = document.createElement("span"); + fpSpan.className = "ml-2 text-xs text-slate-500 font-mono"; + fpSpan.textContent = `${plugin.signature.fingerprint.slice(0, 16)}…`; + fpSpan.title = `Signer fingerprint: ${plugin.signature.fingerprint}`; + subtitle.appendChild(fpSpan); + } + + meta.appendChild(titleRow); meta.appendChild(subtitle); const actions = document.createElement("div"); actions.className = "flex items-center gap-2"; @@ -121,47 +150,65 @@ function renderPlugins(plugins) { ? ' Enabled' : ' Disabled'; toggle.addEventListener("click", async () => { - const res = await fetch(`/api/plugins/${plugin.id}/enable`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enabled: !plugin.enabled }), - }); - if (!res.ok) { - const text = await res.text(); - setStatus(`Enable failed: ${text}`, true); - return; + const wantEnabled = !plugin.enabled; + try { + const res = await fetch(`/api/plugins/${plugin.id}/enable`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: wantEnabled }), + }); + if (!res.ok) { + const data = await res.json().catch(() => null); + if (data && data.error === "confirmation_required") { + showPluginEnableConfirmModal(plugin, data.signature); + return; + } + setStatus(`Enable failed: ${data?.error || res.statusText}`, true); + return; + } + await refresh(); + } catch (err) { + setStatus("Enable failed", true); } - await refresh(); }); const autoLoadBtn = document.createElement("button"); + const autoLoadDisabled = !plugin.enabled; autoLoadBtn.className = "inline-flex items-center gap-2 px-3 py-2 rounded-lg border" + - (plugin.autoLoad - ? " border-amber-600 text-amber-200 bg-amber-900/40" - : " border-slate-600 text-slate-300 bg-slate-800/60"); + (autoLoadDisabled + ? " border-slate-700 text-slate-500 bg-slate-900/40 cursor-not-allowed opacity-50" + : plugin.autoLoad + ? " border-amber-600 text-amber-200 bg-amber-900/40" + : " border-slate-600 text-slate-300 bg-slate-800/60"); autoLoadBtn.innerHTML = plugin.autoLoad ? ' Auto-load' : ' Auto-load off'; - autoLoadBtn.title = plugin.autoLoad - ? "Plugin will auto-load on all new client connections. Click to disable." - : "Click to auto-load this plugin on all new client connections."; - autoLoadBtn.addEventListener("click", async () => { - const res = await fetch(`/api/plugins/${plugin.id}/autoload`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - autoLoad: !plugin.autoLoad, - autoStartEvents: plugin.autoStartEvents || [], - }), + autoLoadBtn.title = autoLoadDisabled + ? "Plugin must be enabled before auto-load can be turned on" + : plugin.autoLoad + ? "Plugin will auto-load on all new client connections. Click to disable." + : "Click to auto-load this plugin on all new client connections."; + if (autoLoadDisabled) { + autoLoadBtn.disabled = true; + } else { + autoLoadBtn.addEventListener("click", async () => { + const res = await fetch(`/api/plugins/${plugin.id}/autoload`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + autoLoad: !plugin.autoLoad, + autoStartEvents: plugin.autoStartEvents || [], + }), + }); + if (!res.ok) { + const text = await res.text(); + setStatus(`Auto-load toggle failed: ${text}`, true); + return; + } + await refresh(); }); - if (!res.ok) { - const text = await res.text(); - setStatus(`Auto-load toggle failed: ${text}`, true); - return; - } - await refresh(); - }); + } const removeBtn = document.createElement("button"); removeBtn.className = @@ -194,6 +241,80 @@ function renderPlugins(plugins) { } } +function showPluginEnableConfirmModal(plugin, sigInfo) { + document.getElementById("plugin-enable-confirm-modal")?.remove(); + + const sig = sigInfo || {}; + let statusText = "This plugin is unsigned."; + let statusColor = "text-orange-400"; + if (sig.signed && sig.valid && !sig.trusted) { + statusText = "This plugin is signed but the signing key is not trusted."; + statusColor = "text-yellow-400"; + } else if (sig.signed && !sig.valid) { + statusText = "This plugin has an invalid signature and may have been tampered with."; + statusColor = "text-red-400"; + } + + const overlay = document.createElement("div"); + overlay.id = "plugin-enable-confirm-modal"; + overlay.className = "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"; + overlay.innerHTML = ` +
+
+ +

Enable Unverified Plugin

+
+

${statusText}

+

Plugin: ${plugin.name || plugin.id}

+ ${sig.fingerprint ? `

Signer: ${sig.fingerprint}

` : '

No signature present.

'} +

Type confirm below to enable this plugin:

+ +
+ + +
+
+ `; + document.body.appendChild(overlay); + + const input = document.getElementById("plugin-enable-confirm-input"); + const okBtn = document.getElementById("plugin-enable-confirm-ok"); + const cancelBtn = document.getElementById("plugin-enable-confirm-cancel"); + + input.addEventListener("input", () => { + const match = input.value.trim().toLowerCase() === "confirm"; + okBtn.disabled = !match; + okBtn.classList.toggle("opacity-50", !match); + okBtn.classList.toggle("cursor-not-allowed", !match); + okBtn.classList.toggle("hover:bg-emerald-800/60", match); + }); + + cancelBtn.addEventListener("click", () => overlay.remove()); + overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); }); + + okBtn.addEventListener("click", async () => { + okBtn.disabled = true; + okBtn.textContent = "Enabling..."; + try { + const res = await fetch(`/api/plugins/${plugin.id}/enable`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: true, confirmed: true }), + }); + if (!res.ok) { + const text = await res.text(); + setStatus(`Enable failed: ${text}`, true); + } + } catch { + setStatus("Enable failed", true); + } + overlay.remove(); + await refresh(); + }); + + input.focus(); +} + async function refresh() { const plugins = await fetchPlugins(); renderPlugins(plugins); @@ -237,5 +358,99 @@ fileInput?.addEventListener("change", (e) => { refreshBtn?.addEventListener("click", refresh); +const trustedKeysSection = document.getElementById("trusted-keys-section"); +const trustedKeysList = document.getElementById("trusted-keys-list"); +const addKeyBtn = document.getElementById("add-trusted-key-btn"); +const newKeyInput = document.getElementById("new-trusted-key-input"); + +let _builtinKeys = []; + +async function fetchTrustedKeys() { + try { + const res = await fetch("/api/plugins/trusted-keys"); + if (!res.ok) { + if (res.status === 403) { + if (trustedKeysSection) trustedKeysSection.classList.add("hidden"); + return []; + } + return []; + } + const data = await res.json(); + if (trustedKeysSection) trustedKeysSection.classList.remove("hidden"); + _builtinKeys = Array.isArray(data.builtinKeys) ? data.builtinKeys : []; + return Array.isArray(data.trustedKeys) ? data.trustedKeys : []; + } catch { + return []; + } +} + +function renderTrustedKeys(keys) { + if (!trustedKeysList) return; + trustedKeysList.innerHTML = ""; + if (!keys.length) { + trustedKeysList.innerHTML = '
No trusted keys configured. All plugins will require confirmation to load.
'; + return; + } + for (const key of keys) { + const isBuiltin = _builtinKeys.includes(key); + const row = document.createElement("div"); + row.className = "flex items-center gap-2 px-3 py-2 rounded-lg border border-slate-800 bg-slate-950/50"; + const fp = document.createElement("span"); + fp.className = "font-mono text-sm text-slate-300 flex-1 truncate"; + fp.textContent = key; + fp.title = key; + row.appendChild(fp); + if (isBuiltin) { + const badge = document.createElement("span"); + badge.className = "text-xs text-emerald-400 px-2 py-0.5 rounded bg-emerald-900/30 whitespace-nowrap"; + badge.textContent = "built-in"; + badge.title = "This key is hardcoded and always trusted"; + row.appendChild(badge); + } else { + const removeBtn = document.createElement("button"); + removeBtn.className = "text-red-400 hover:text-red-300 text-sm px-2 py-1"; + removeBtn.innerHTML = ''; + removeBtn.title = "Remove trusted key"; + removeBtn.addEventListener("click", async () => { + const res = await fetch(`/api/plugins/trusted-keys/${key}`, { method: "DELETE" }); + if (res.ok) { + await refreshTrustedKeys(); + await refresh(); + } + }); + row.appendChild(removeBtn); + } + trustedKeysList.appendChild(row); + } +} + +async function refreshTrustedKeys() { + const keys = await fetchTrustedKeys(); + renderTrustedKeys(keys); +} + +addKeyBtn?.addEventListener("click", async () => { + const fp = newKeyInput?.value?.trim().toLowerCase(); + if (!fp || !/^[a-f0-9]{64}$/.test(fp)) { + setStatus("Invalid fingerprint — must be a 64-character hex string (SHA-256)", true); + return; + } + const res = await fetch("/api/plugins/trusted-keys", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fingerprint: fp }), + }); + if (res.ok) { + if (newKeyInput) newKeyInput.value = ""; + setStatus("Trusted key added."); + await refreshTrustedKeys(); + await refresh(); + } else { + const data = await res.json().catch(() => ({})); + setStatus(`Failed to add key: ${data.error || "unknown error"}`, true); + } +}); + checkAuth(); refresh(); +refreshTrustedKeys(); diff --git a/Overlord-Server/public/plugins.html b/Overlord-Server/public/plugins.html index 18b3cb6..16efe24 100644 --- a/Overlord-Server/public/plugins.html +++ b/Overlord-Server/public/plugins.html @@ -65,6 +65,27 @@
+ + diff --git a/Overlord-Server/scripts/plugin-keygen.ts b/Overlord-Server/scripts/plugin-keygen.ts new file mode 100644 index 0000000..e103099 --- /dev/null +++ b/Overlord-Server/scripts/plugin-keygen.ts @@ -0,0 +1,51 @@ +import { writeFileSync } from "fs"; +import { resolve } from "path"; + +async function main() { + const args = process.argv.slice(2); + let prefix = "plugin-signing"; + + for (let i = 0; i < args.length; i++) { + if (args[i] === "--out" && args[i + 1]) { + prefix = args[i + 1]; + i++; + } + } + + const keyPair = await crypto.subtle.generateKey("Ed25519", true, [ + "sign", + "verify", + ]); + + const privRaw = await crypto.subtle.exportKey("pkcs8", keyPair.privateKey); + const privBase64 = Buffer.from(privRaw).toString("base64"); + + const pubRaw = await crypto.subtle.exportKey("raw", keyPair.publicKey); + const pubBase64 = Buffer.from(pubRaw).toString("base64"); + + const fingerprintBuf = await crypto.subtle.digest("SHA-256", pubRaw); + const fingerprint = Buffer.from(fingerprintBuf).toString("hex"); + + const keyPath = resolve(prefix + ".key"); + const pubPath = resolve(prefix + ".pub"); + + writeFileSync(keyPath, privBase64 + "\n", { mode: 0o600 }); + writeFileSync( + pubPath, + `${pubBase64}\n# fingerprint: ${fingerprint}\n`, + { mode: 0o644 }, + ); + + console.log(`Key pair generated:`); + console.log(` Private key: ${keyPath}`); + console.log(` Public key: ${pubPath}`); + console.log(` Fingerprint: ${fingerprint}`); + console.log(``); + console.log(`Add this fingerprint to your server config to trust plugins signed with this key:`); + console.log(` "plugins": { "trustedKeys": ["${fingerprint}"] }`); +} + +main().catch((err) => { + console.error("Error:", err.message || err); + process.exit(1); +}); diff --git a/Overlord-Server/scripts/plugin-sign.ts b/Overlord-Server/scripts/plugin-sign.ts new file mode 100644 index 0000000..f6383ee --- /dev/null +++ b/Overlord-Server/scripts/plugin-sign.ts @@ -0,0 +1,111 @@ +#!/usr/bin/env bun + + +import { readFileSync, writeFileSync } from "fs"; +import { resolve, basename } from "path"; +import AdmZip from "adm-zip"; + +async function computeContentDigest(zip: InstanceType): Promise { + const entries = zip.getEntries(); + const fileHashes: Array<{ name: string; hash: string }> = []; + + for (const entry of entries) { + if (entry.isDirectory) continue; + const name = entry.entryName; + if (name === "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, hash }); + } + + fileHashes.sort((a, b) => a.name.localeCompare(b.name)); + return fileHashes.map((f) => `${f.name}:${f.hash}\n`).join(""); +} + +async function main() { + const args = process.argv.slice(2); + let keyPath = ""; + let zipPath = ""; + + for (let i = 0; i < args.length; i++) { + if (args[i] === "--key" && args[i + 1]) { + keyPath = args[i + 1]; + i++; + } else if (!args[i].startsWith("-")) { + zipPath = args[i]; + } + } + + if (!keyPath || !zipPath) { + console.error("Usage: bun run scripts/plugin-sign.ts --key "); + process.exit(1); + } + + keyPath = resolve(keyPath); + zipPath = resolve(zipPath); + + const privBase64 = readFileSync(keyPath, "utf-8").trim(); + const privBytes = Buffer.from(privBase64, "base64"); + const privateKey = await crypto.subtle.importKey( + "pkcs8", + privBytes, + { name: "Ed25519" }, + true, + ["sign"], + ); + + const jwk = await crypto.subtle.exportKey("jwk", privateKey); + const { d: _privMaterial, ...publicJwk } = jwk; + const publicKey = await crypto.subtle.importKey( + "jwk", + { ...publicJwk, key_ops: ["verify"] }, + { name: "Ed25519" }, + true, + ["verify"], + ); + const pubRaw = await crypto.subtle.exportKey("raw", publicKey); + const pubBase64 = Buffer.from(pubRaw).toString("base64"); + + const fingerprintBuf = await crypto.subtle.digest("SHA-256", pubRaw); + const fingerprint = Buffer.from(fingerprintBuf).toString("hex"); + + const zip = new AdmZip(zipPath); + const digest = await computeContentDigest(zip); + + if (!digest) { + console.error("Error: ZIP contains no signable files"); + process.exit(1); + } + + const digestBytes = new TextEncoder().encode(digest); + const sigBytes = await crypto.subtle.sign("Ed25519", privateKey, digestBytes); + const sigBase64 = Buffer.from(sigBytes).toString("base64"); + + const signatureJson = JSON.stringify( + { + algorithm: "ed25519", + publicKey: pubBase64, + signature: sigBase64, + }, + null, + 2, + ); + + try { + zip.deleteFile("signature.json"); + } catch {} + + zip.addFile("signature.json", Buffer.from(signatureJson, "utf-8")); + zip.writeZip(zipPath); + + console.log(`Signed: ${basename(zipPath)}`); + console.log(` Signer fingerprint: ${fingerprint}`); + console.log(` Public key (base64): ${pubBase64}`); +} + +main().catch((err) => { + console.error("Error:", err.message || err); + process.exit(1); +}); diff --git a/Overlord-Server/src/config.ts b/Overlord-Server/src/config.ts index 85d3f55..5fb9fad 100644 --- a/Overlord-Server/src/config.ts +++ b/Overlord-Server/src/config.ts @@ -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, +): Promise { + 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 { const config = getConfig(); return { @@ -632,6 +672,7 @@ export function getExportableConfig(serverVersion: string): Record): 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"); diff --git a/Overlord-Server/src/protocol.ts b/Overlord-Server/src/protocol.ts index a70e665..bc2d891 100644 --- a/Overlord-Server/src/protocol.ts +++ b/Overlord-Server/src/protocol.ts @@ -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; diff --git a/Overlord-Server/src/server/plugin-signature.ts b/Overlord-Server/src/server/plugin-signature.ts new file mode 100644 index 0000000..b659e5a --- /dev/null +++ b/Overlord-Server/src/server/plugin-signature.ts @@ -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): Promise { + 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, +): { 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 { + 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 { + let zip: InstanceType; + 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 { + 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; +} diff --git a/Overlord-Server/src/server/plugin-state-bundle.ts b/Overlord-Server/src/server/plugin-state-bundle.ts index 0555d02..d17f405 100644 --- a/Overlord-Server/src/server/plugin-state-bundle.ts +++ b/Overlord-Server/src/server/plugin-state-bundle.ts @@ -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; @@ -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, ensureExtracted: (pluginId: string) => Promise, -): Promise { +): Promise { 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(); diff --git a/Overlord-Server/src/server/routes/plugin-routes.ts b/Overlord-Server/src/server/routes/plugin-routes.ts index 6cb6e14..762ccf5 100644 --- a/Overlord-Server/src/server/routes/plugin-routes.ts +++ b/Overlord-Server/src/server/routes/plugin-routes.ts @@ -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 }); } diff --git a/PLUGINS.md b/PLUGINS.md index 4ca3aaa..523e6ef 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -787,4 +787,96 @@ const { files } = await (await fetch(`/api/plugins/myplugin/data`)).json(); - The data directory is created automatically on first use (no pre-setup needed) - Deleting and reinstalling a plugin leaves `data/` untouched +--- + +## Plugin Signing + +Plugins can be cryptographically signed with Ed25519 keys. The server verifies signatures on upload and displays trust status in the dashboard. Loading unsigned or untrusted plugins requires explicit confirmation. + +### Trust Levels + +| Status | Badge | Behavior | +|--------|-------|----------| +| **Signed + Trusted** | Green shield | Loads immediately | +| **Signed + Untrusted** | Yellow shield | Must type "confirm" to load | +| **Unsigned** | Orange shield | Must type "confirm" to load | +| **Invalid Signature** | Red shield | Blocked — cannot load | + +### Generate a Signing Key + +```bash +cd Overlord-Server +bun run scripts/plugin-keygen.ts --out my-signing-key +``` + +This creates: +- `my-signing-key.key` — private key (keep secret!) +- `my-signing-key.pub` — public key + fingerprint + +The fingerprint (64-char hex SHA-256) is printed to the console. Add it to your server config to trust plugins signed with this key. + +### Sign a Plugin + +```bash +cd Overlord-Server +bun run scripts/plugin-sign.ts --key my-signing-key.key ../plugin-sample-go/sample.zip +``` + +This injects a `signature.json` into the ZIP containing the Ed25519 public key and signature. + +### Add a Trusted Key + +**Via config.json:** +```json +{ + "plugins": { + "trustedKeys": [ + "a1b2c3d4e5f6...64-char-hex-fingerprint..." + ] + } +} +``` + +**Via environment variable:** +``` +TRUSTED_PLUGIN_KEYS=fingerprint1,fingerprint2 +``` + +**Via the web UI:** Go to the Plugins page → Trusted Signing Keys section → paste the fingerprint and click Add Key. + +**Via API:** +```bash +# Add a trusted key +curl -X POST /api/plugins/trusted-keys \ + -H "Content-Type: application/json" \ + -d '{"fingerprint": "a1b2c3..."}' + +# List trusted keys +curl /api/plugins/trusted-keys + +# Remove a trusted key +curl -X DELETE /api/plugins/trusted-keys/a1b2c3... +``` + +### Build Script Integration + +Set the `PLUGIN_SIGN_KEY` environment variable to automatically sign plugins during build: + +```bash +# Unix +PLUGIN_SIGN_KEY=path/to/my-signing-key.key ./build-plugin.sh + +# Windows +set PLUGIN_SIGN_KEY=path\to\my-signing-key.key +build-plugin.bat +``` + +### How Signing Works + +1. The canonical content digest is computed by hashing every file in the ZIP (excluding `signature.json`), sorting filenames alphabetically, and concatenating `filename:sha256hex\n` for each +2. The digest is signed with Ed25519 using the private key +3. The signature, public key, and algorithm are stored as `signature.json` inside the ZIP +4. On upload, the server extracts `signature.json`, recomputes the digest, and verifies the signature using `crypto.subtle.verify("Ed25519", ...)` +5. The signer's fingerprint (`hex(SHA-256(raw_public_key))`) is compared against `plugins.trustedKeys` in the config + diff --git a/build-plugin.bat b/build-plugin.bat index 2e9ae87..b5df345 100644 --- a/build-plugin.bat +++ b/build-plugin.bat @@ -87,4 +87,15 @@ if errorlevel 1 ( exit /b 1 ) +REM Optional: sign the plugin if PLUGIN_SIGN_KEY is set +if defined PLUGIN_SIGN_KEY ( + where bun >nul 2>&1 + if not errorlevel 1 ( + echo [sign] Signing plugin with key: %PLUGIN_SIGN_KEY% + bun run "%ROOT%Overlord-Server\scripts\plugin-sign.ts" --key "%PLUGIN_SIGN_KEY%" "%ZIP_OUT%" + ) else ( + echo [warn] bun not found, skipping plugin signing + ) +) + echo [ok] %ZIP_OUT% diff --git a/build-plugin.sh b/build-plugin.sh index dbac823..7cdec1f 100644 --- a/build-plugin.sh +++ b/build-plugin.sh @@ -67,4 +67,15 @@ else exit 1 fi +# Optional: sign the plugin if PLUGIN_SIGN_KEY is set +if [[ -n "${PLUGIN_SIGN_KEY:-}" ]]; then + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + if command -v bun >/dev/null 2>&1; then + echo "[sign] Signing plugin with key: ${PLUGIN_SIGN_KEY}" + bun run "${SCRIPT_DIR}/Overlord-Server/scripts/plugin-sign.ts" --key "${PLUGIN_SIGN_KEY}" "${ZIP_OUT}" + else + echo "[warn] bun not found, skipping plugin signing" >&2 + fi +fi + echo "[ok] ${ZIP_OUT}"