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 @@
+
+
+ Trusted Signing Keys
+ Plugins signed by a trusted key can be loaded without confirmation. Add key fingerprints (SHA-256 hex) here.
+
+
+
+
+
+
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}"