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
+3
View File
@@ -35,5 +35,8 @@
}, },
"enrollment": { "enrollment": {
"requireApproval": true "requireApproval": true
},
"plugins": {
"trustedKeys": []
} }
} }
+2
View File
@@ -0,0 +1,2 @@
IqAQbR5RB+YOhFg3HILNtqqULAU0Z4YAbSEZXDNvDv4=
# fingerprint: c0f7b94f9678425d08d2408fd92494c419a5ea6819ee4dbcebc41d86c22c61fa
+130 -2
View File
@@ -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 = `
<div class="bg-slate-900 border border-slate-700 rounded-xl shadow-2xl max-w-md w-full mx-4 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full flex items-center justify-center ${isSigned ? 'bg-yellow-900/60 text-yellow-400' : 'bg-orange-900/60 text-orange-400'}">
<i class="fa-solid ${isSigned ? 'fa-shield' : 'fa-shield-halved'} text-lg"></i>
</div>
<div>
<h3 class="font-semibold text-lg">${isSigned ? 'Untrusted Plugin' : 'Unsigned Plugin'}</h3>
<p class="text-sm text-slate-400">${pluginId}</p>
</div>
</div>
<p class="text-sm text-slate-300 mb-2">${statusText}</p>
<p class="text-xs text-slate-500 font-mono mb-4">${fpText}</p>
<p class="text-sm text-slate-300 mb-3">Type <strong class="text-white">confirm</strong> below to load this plugin:</p>
<input
id="plugin-confirm-input"
type="text"
placeholder="Type confirm…"
autocomplete="off"
class="w-full px-3 py-2 rounded-lg bg-slate-950 border border-slate-700 text-slate-100 text-sm placeholder-slate-500 focus:outline-none focus:border-emerald-600 mb-4"
/>
<div class="flex gap-3 justify-end">
<button id="plugin-confirm-cancel" class="px-4 py-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-800">Cancel</button>
<button id="plugin-confirm-load" disabled class="px-4 py-2 rounded-lg bg-emerald-900/40 border border-emerald-700/60 text-emerald-100 opacity-50 cursor-not-allowed">Load Anyway</button>
</div>
</div>
`;
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) { async function loadPluginsForClient(clientId) {
const section = document.getElementById("plugin-section"); const section = document.getElementById("plugin-section");
const container = document.getElementById("plugin-menu"); const container = document.getElementById("plugin-menu");
@@ -336,11 +419,38 @@ async function loadPluginsForClient(clientId) {
if (plugin.lastError) { if (plugin.lastError) {
btn.title = `Last error: ${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"); const label = document.createElement("span");
label.className = "flex items-center gap-1";
const labelIcon = document.createElement("i"); const labelIcon = document.createElement("i");
labelIcon.className = "fa-solid fa-puzzle-piece"; labelIcon.className = "fa-solid fa-puzzle-piece";
label.appendChild(labelIcon); label.appendChild(labelIcon);
label.append(` ${plugin.name || plugin.id}`); 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"); const badge = document.createElement("span");
badge.className = badge.className =
"text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full border" + "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; const pluginId = target.dataset.plugin;
if (pluginId) { if (pluginId) {
const savedClientId = contextCard;
try { try {
const res = await fetch(`/api/clients/${contextCard}/plugins/${pluginId}/load`, { const res = await fetch(`/api/clients/${savedClientId}/plugins/${pluginId}/load`, {
method: "POST", 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) { if (!res.ok) {
const text = await res.text(); const text = await res.text();
alert(`Plugin load failed: ${text}`); alert(`Plugin load failed: ${text}`);
closeMenu(clearContext); closeMenu(clearContext);
return; return;
} }
window.open(`/plugins/${pluginId}?clientId=${contextCard}`, "_blank", "noopener"); window.open(`/plugins/${pluginId}?clientId=${savedClientId}`, "_blank", "noopener");
} catch (err) { } catch (err) {
alert("Plugin load failed"); alert("Plugin load failed");
} }
+248 -33
View File
@@ -88,6 +88,14 @@ async function fetchPlugins() {
return Array.isArray(data.plugins) ? data.plugins : []; 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) { function renderPlugins(plugins) {
pluginList.innerHTML = ""; pluginList.innerHTML = "";
if (!plugins.length) { if (!plugins.length) {
@@ -100,13 +108,34 @@ function renderPlugins(plugins) {
card.className = card.className =
"rounded-xl border border-slate-800 bg-slate-950/50 px-4 py-3 flex items-center justify-between"; "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 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.className = "font-semibold";
title.textContent = plugin.name || plugin.id; 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 = `<i class="fa-solid ${badge.icon}"></i> ${badge.label}`;
trustBadge.title = badge.tooltip;
titleRow.appendChild(trustBadge);
const subtitle = document.createElement("div"); const subtitle = document.createElement("div");
subtitle.className = "text-sm text-slate-400"; subtitle.className = "text-sm text-slate-400";
subtitle.textContent = `${plugin.id}${plugin.version ? ` • v${plugin.version}` : ""}`; 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); meta.appendChild(subtitle);
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = "flex items-center gap-2"; actions.className = "flex items-center gap-2";
@@ -121,47 +150,65 @@ function renderPlugins(plugins) {
? '<i class="fa-solid fa-toggle-on"></i> Enabled' ? '<i class="fa-solid fa-toggle-on"></i> Enabled'
: '<i class="fa-solid fa-toggle-off"></i> Disabled'; : '<i class="fa-solid fa-toggle-off"></i> Disabled';
toggle.addEventListener("click", async () => { toggle.addEventListener("click", async () => {
const res = await fetch(`/api/plugins/${plugin.id}/enable`, { const wantEnabled = !plugin.enabled;
method: "POST", try {
headers: { "Content-Type": "application/json" }, const res = await fetch(`/api/plugins/${plugin.id}/enable`, {
body: JSON.stringify({ enabled: !plugin.enabled }), method: "POST",
}); headers: { "Content-Type": "application/json" },
if (!res.ok) { body: JSON.stringify({ enabled: wantEnabled }),
const text = await res.text(); });
setStatus(`Enable failed: ${text}`, true); if (!res.ok) {
return; 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 autoLoadBtn = document.createElement("button");
const autoLoadDisabled = !plugin.enabled;
autoLoadBtn.className = autoLoadBtn.className =
"inline-flex items-center gap-2 px-3 py-2 rounded-lg border" + "inline-flex items-center gap-2 px-3 py-2 rounded-lg border" +
(plugin.autoLoad (autoLoadDisabled
? " border-amber-600 text-amber-200 bg-amber-900/40" ? " border-slate-700 text-slate-500 bg-slate-900/40 cursor-not-allowed opacity-50"
: " border-slate-600 text-slate-300 bg-slate-800/60"); : 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 autoLoadBtn.innerHTML = plugin.autoLoad
? '<i class="fa-solid fa-bolt"></i> Auto-load' ? '<i class="fa-solid fa-bolt"></i> Auto-load'
: '<i class="fa-solid fa-bolt-lightning"></i> Auto-load off'; : '<i class="fa-solid fa-bolt-lightning"></i> Auto-load off';
autoLoadBtn.title = plugin.autoLoad autoLoadBtn.title = autoLoadDisabled
? "Plugin will auto-load on all new client connections. Click to disable." ? "Plugin must be enabled before auto-load can be turned on"
: "Click to auto-load this plugin on all new client connections."; : plugin.autoLoad
autoLoadBtn.addEventListener("click", async () => { ? "Plugin will auto-load on all new client connections. Click to disable."
const res = await fetch(`/api/plugins/${plugin.id}/autoload`, { : "Click to auto-load this plugin on all new client connections.";
method: "POST", if (autoLoadDisabled) {
headers: { "Content-Type": "application/json" }, autoLoadBtn.disabled = true;
body: JSON.stringify({ } else {
autoLoad: !plugin.autoLoad, autoLoadBtn.addEventListener("click", async () => {
autoStartEvents: plugin.autoStartEvents || [], 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"); const removeBtn = document.createElement("button");
removeBtn.className = 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 = `
<div class="bg-slate-900 border border-slate-700 rounded-2xl p-6 max-w-md w-full mx-4 shadow-2xl">
<div class="flex items-center gap-3 mb-4">
<i class="fa-solid fa-triangle-exclamation text-2xl ${statusColor}"></i>
<h3 class="text-lg font-semibold text-slate-100">Enable Unverified Plugin</h3>
</div>
<p class="text-sm text-slate-300 mb-2">${statusText}</p>
<p class="text-sm text-slate-400 mb-1">Plugin: <strong class="text-slate-200">${plugin.name || plugin.id}</strong></p>
${sig.fingerprint ? `<p class="text-xs text-slate-500 font-mono mb-3">Signer: ${sig.fingerprint}</p>` : '<p class="text-xs text-slate-500 mb-3">No signature present.</p>'}
<p class="text-sm text-slate-400" style="margin-bottom: 16px;">Type <strong class="text-emerald-300">confirm</strong> below to enable this plugin:</p>
<input type="text" id="plugin-enable-confirm-input" class="w-full px-3 py-2 rounded-lg bg-slate-800 border border-slate-600 text-slate-100 text-sm focus:outline-none focus:border-emerald-500" style="margin-bottom: 16px;" placeholder="Type confirm" autocomplete="off" spellcheck="false" />
<div class="flex justify-end gap-2">
<button id="plugin-enable-confirm-cancel" class="px-4 py-2 rounded-lg bg-slate-800 border border-slate-600 text-slate-300 hover:bg-slate-700">Cancel</button>
<button id="plugin-enable-confirm-ok" disabled class="px-4 py-2 rounded-lg bg-emerald-900/40 border border-emerald-700/60 text-emerald-100 opacity-50 cursor-not-allowed">Enable Anyway</button>
</div>
</div>
`;
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() { async function refresh() {
const plugins = await fetchPlugins(); const plugins = await fetchPlugins();
renderPlugins(plugins); renderPlugins(plugins);
@@ -237,5 +358,99 @@ fileInput?.addEventListener("change", (e) => {
refreshBtn?.addEventListener("click", refresh); 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 = '<div class="text-slate-500 text-sm">No trusted keys configured. All plugins will require confirmation to load.</div>';
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 = '<i class="fa-solid fa-xmark"></i>';
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(); checkAuth();
refresh(); refresh();
refreshTrustedKeys();
+21
View File
@@ -65,6 +65,27 @@
</div> </div>
<div id="plugin-list" class="mt-4 grid gap-3"></div> <div id="plugin-list" class="mt-4 grid gap-3"></div>
</section> </section>
<section id="trusted-keys-section" class="hidden rounded-2xl border border-slate-800 bg-slate-900/50 p-5">
<h2 class="text-lg font-semibold"><i class="fa-solid fa-key mr-2"></i>Trusted Signing Keys</h2>
<p class="text-slate-400 text-sm mt-1">Plugins signed by a trusted key can be loaded without confirmation. Add key fingerprints (SHA-256 hex) here.</p>
<div id="trusted-keys-list" class="mt-4 grid gap-2"></div>
<div class="mt-4 flex gap-2">
<input
id="new-trusted-key-input"
type="text"
placeholder="Paste 64-char hex fingerprint…"
class="flex-1 px-3 py-2 rounded-lg bg-slate-950 border border-slate-700 text-slate-100 font-mono text-sm placeholder-slate-500 focus:outline-none focus:border-emerald-600"
maxlength="64"
/>
<button
id="add-trusted-key-btn"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-900/40 border border-emerald-700/60 hover:bg-emerald-800/60 text-emerald-100"
>
<i class="fa-solid fa-plus"></i> Add Key
</button>
</div>
</section>
</div> </div>
</main> </main>
<script type="module" src="/assets/nav.js"></script> <script type="module" src="/assets/nav.js"></script>
+51
View File
@@ -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);
});
+111
View File
@@ -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<typeof AdmZip>): Promise<string> {
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 <private.key> <plugin.zip>");
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);
});
+46
View File
@@ -58,6 +58,9 @@ export interface Config {
appearance: { appearance: {
customCSS: string; customCSS: string;
}; };
plugins: {
trustedKeys: string[];
};
} }
const DEFAULT_CONFIG: Config = { const DEFAULT_CONFIG: Config = {
@@ -114,6 +117,9 @@ const DEFAULT_CONFIG: Config = {
appearance: { appearance: {
customCSS: "", customCSS: "",
}, },
plugins: {
trustedKeys: [],
},
}; };
type SaveSecrets = { type SaveSecrets = {
@@ -432,6 +438,15 @@ export function loadConfig(): Config {
appearance: { appearance: {
customCSS: fileConfig.appearance?.customCSS || DEFAULT_CONFIG.appearance.customCSS, 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) { if (saveChanged) {
@@ -616,6 +631,31 @@ export async function updateAppearanceConfig(customCSS: string): Promise<Config[
return next; return next;
} }
export async function updatePluginsConfig(
updates: Partial<Config["plugins"]>,
): Promise<Config["plugins"]> {
const current = getConfig();
const trustedKeys = (updates.trustedKeys || current.plugins.trustedKeys || [])
.map((k) => String(k).trim().toLowerCase())
.filter(Boolean);
const deduped = Array.from(new Set(trustedKeys));
const next: Config["plugins"] = {
...current.plugins,
trustedKeys: deduped,
};
configCache = {
...current,
plugins: next,
};
const fileConfig = readFileConfigForUpdate();
fileConfig.plugins = next;
await writePersistentFileConfig(fileConfig);
return next;
}
export function getExportableConfig(serverVersion: string): Record<string, unknown> { export function getExportableConfig(serverVersion: string): Record<string, unknown> {
const config = getConfig(); const config = getConfig();
return { return {
@@ -632,6 +672,7 @@ export function getExportableConfig(serverVersion: string): Record<string, unkno
tls: config.tls, tls: config.tls,
enrollment: config.enrollment, enrollment: config.enrollment,
appearance: config.appearance, appearance: config.appearance,
plugins: config.plugins,
}; };
} }
@@ -685,6 +726,11 @@ export async function importFullConfig(data: Record<string, any>): Promise<{ app
} }
} }
if (data.plugins && typeof data.plugins === "object") {
await updatePluginsConfig(data.plugins);
applied.push("plugins");
}
if (data.auth && typeof data.auth === "object") { if (data.auth && typeof data.auth === "object") {
const dataDir = ensureDataDir(); const dataDir = ensureDataDir();
const savePath = resolve(dataDir, "save.json"); const savePath = resolve(dataDir, "save.json");
+8
View File
@@ -270,6 +270,14 @@ export type PluginManifest = {
}; };
}; };
export type PluginSignatureInfo = {
signed: boolean;
trusted: boolean;
valid: boolean;
fingerprint?: string;
algorithm?: string;
};
export type PluginEvent = { export type PluginEvent = {
type: "plugin_event"; type: "plugin_event";
pluginId: string; pluginId: string;
@@ -0,0 +1,169 @@
import fs from "fs/promises";
import path from "path";
import AdmZip from "adm-zip";
import type { PluginSignatureInfo } from "../protocol";
import { logger } from "../logger";
import { getConfig } from "../config";
export const BUILTIN_TRUSTED_KEYS: string[] = [
"c0f7b94f9678425d08d2408fd92494c419a5ea6819ee4dbcebc41d86c22c61fa",
];
export async function computeContentDigest(zip: InstanceType<typeof AdmZip>): Promise<string> {
const entries = zip.getEntries();
const fileHashes: Array<{ name: string; hash: string }> = [];
for (const entry of entries) {
if (entry.isDirectory) continue;
if (entry.entryName === "signature.json") continue;
const data = entry.getData();
const hashBuf = await crypto.subtle.digest("SHA-256", data);
const hash = Buffer.from(hashBuf).toString("hex");
fileHashes.push({ name: entry.entryName, hash });
}
fileHashes.sort((a, b) => a.name.localeCompare(b.name));
return fileHashes.map((f) => `${f.name}:${f.hash}\n`).join("");
}
export function extractSignatureFromZip(
zip: InstanceType<typeof AdmZip>,
): { algorithm: string; publicKey: string; signature: string } | null {
const entry = zip.getEntry("signature.json");
if (!entry) return null;
try {
const raw = entry.getData().toString("utf-8");
const parsed = JSON.parse(raw);
if (
typeof parsed.algorithm === "string" &&
typeof parsed.publicKey === "string" &&
typeof parsed.signature === "string"
) {
return {
algorithm: parsed.algorithm,
publicKey: parsed.publicKey,
signature: parsed.signature,
};
}
return null;
} catch {
return null;
}
}
export async function computeKeyFingerprint(publicKeyBase64: string): Promise<string> {
const pubBytes = Buffer.from(publicKeyBase64, "base64");
const hashBuf = await crypto.subtle.digest("SHA-256", pubBytes);
return Buffer.from(hashBuf).toString("hex");
}
export function isKeyTrusted(fingerprint: string, trustedKeys: string[]): boolean {
const normalized = fingerprint.toLowerCase();
const allTrusted = [...BUILTIN_TRUSTED_KEYS, ...trustedKeys];
return allTrusted.some((k) => k.toLowerCase() === normalized);
}
export async function verifyPluginSignature(zipPath: string): Promise<PluginSignatureInfo> {
let zip: InstanceType<typeof AdmZip>;
try {
zip = new AdmZip(zipPath);
} catch (err) {
logger.warn(`[plugin-signature] failed to read ZIP ${zipPath}: ${(err as Error).message}`);
return { signed: false, trusted: false, valid: false };
}
const sigData = extractSignatureFromZip(zip);
if (!sigData) {
return { signed: false, trusted: false, valid: false };
}
if (sigData.algorithm !== "ed25519") {
logger.warn(`[plugin-signature] unsupported algorithm: ${sigData.algorithm}`);
return { signed: true, trusted: false, valid: false, algorithm: sigData.algorithm };
}
try {
const pubBytes = Buffer.from(sigData.publicKey, "base64");
if (pubBytes.length !== 32) {
return { signed: true, trusted: false, valid: false, algorithm: sigData.algorithm };
}
const publicKey = await crypto.subtle.importKey(
"raw",
pubBytes,
{ name: "Ed25519" },
false,
["verify"],
);
const digest = await computeContentDigest(zip);
const digestBytes = new TextEncoder().encode(digest);
const sigBytes = Buffer.from(sigData.signature, "base64");
if (sigBytes.length !== 64) {
return { signed: true, trusted: false, valid: false, algorithm: sigData.algorithm };
}
const valid = await crypto.subtle.verify("Ed25519", publicKey, sigBytes, digestBytes);
const fingerprint = await computeKeyFingerprint(sigData.publicKey);
const config = getConfig();
const trusted = valid && isKeyTrusted(fingerprint, config.plugins.trustedKeys);
return {
signed: true,
valid,
trusted,
fingerprint,
algorithm: sigData.algorithm,
};
} catch (err) {
logger.warn(`[plugin-signature] verification error: ${(err as Error).message}`);
return { signed: true, trusted: false, valid: false, algorithm: sigData.algorithm };
}
}
export async function getOrVerifySignature(
pluginRoot: string,
pluginId: string,
): Promise<PluginSignatureInfo> {
const sigInfoPath = path.join(pluginRoot, pluginId, "signature-info.json");
const zipPath = path.join(pluginRoot, `${pluginId}.zip`);
try {
const [zipStat, sigInfoStat] = await Promise.all([
fs.stat(zipPath),
fs.stat(sigInfoPath),
]);
if (sigInfoStat.mtimeMs >= zipStat.mtimeMs) {
const raw = await fs.readFile(sigInfoPath, "utf-8");
const cached = JSON.parse(raw) as PluginSignatureInfo;
if (cached.signed && cached.valid && cached.fingerprint) {
const config = getConfig();
cached.trusted = isKeyTrusted(cached.fingerprint, config.plugins.trustedKeys);
}
return cached;
}
} catch {
}
try {
await fs.stat(zipPath);
} catch {
return { signed: false, trusted: false, valid: false };
}
const info = await verifyPluginSignature(zipPath);
try {
await fs.mkdir(path.join(pluginRoot, pluginId), { recursive: true });
await fs.writeFile(sigInfoPath, JSON.stringify(info, null, 2));
} catch (err) {
logger.warn(`[plugin-signature] failed to cache signature info: ${(err as Error).message}`);
}
return info;
}
@@ -4,7 +4,8 @@ import AdmZip from "adm-zip";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import type { ClientInfo } from "../types"; import type { ClientInfo } from "../types";
import { logger } from "../logger"; 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 = { export type PluginState = {
enabled: Record<string, boolean>; enabled: Record<string, boolean>;
@@ -126,6 +127,14 @@ export async function ensurePluginExtracted(
}, },
}; };
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2)); 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( export async function syncPluginBundles(
@@ -146,16 +155,20 @@ export async function syncPluginBundles(
} }
} }
export type PluginManifestWithSignature = PluginManifest & {
signature?: PluginSignatureInfo;
};
export async function listPluginManifests( export async function listPluginManifests(
pluginRoot: string, pluginRoot: string,
pluginState: PluginState, pluginState: PluginState,
saveState: () => Promise<void>, saveState: () => Promise<void>,
ensureExtracted: (pluginId: string) => Promise<void>, ensureExtracted: (pluginId: string) => Promise<void>,
): Promise<PluginManifest[]> { ): Promise<PluginManifestWithSignature[]> {
try { try {
await syncPluginBundles(pluginRoot, ensureExtracted); await syncPluginBundles(pluginRoot, ensureExtracted);
const entries = await fs.readdir(pluginRoot, { withFileTypes: true }); const entries = await fs.readdir(pluginRoot, { withFileTypes: true });
const manifests: PluginManifest[] = []; const manifests: PluginManifestWithSignature[] = [];
for (const ent of entries) { for (const ent of entries) {
if (!ent.isDirectory()) continue; if (!ent.isDirectory()) continue;
const manifestPath = path.join(pluginRoot, ent.name, "manifest.json"); const manifestPath = path.join(pluginRoot, ent.name, "manifest.json");
@@ -167,7 +180,8 @@ export async function listPluginManifests(
if (pluginState.enabled[id] === undefined) { if (pluginState.enabled[id] === undefined) {
pluginState.enabled[id] = true; pluginState.enabled[id] = true;
} }
manifests.push({ ...manifest, id, name }); const sigInfo = await getOrVerifySignature(pluginRoot, id);
manifests.push({ ...manifest, id, name, signature: sigInfo });
} catch {} } catch {}
} }
await saveState(); await saveState();
@@ -6,11 +6,14 @@ import { requirePermission } from "../../rbac";
import { canUserAccessClient } from "../../users"; import { canUserAccessClient } from "../../users";
import * as clientManager from "../../clientManager"; import * as clientManager from "../../clientManager";
import { metrics } from "../../metrics"; 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 = { type PluginManifest = {
id: string; id: string;
name: string; name: string;
signature?: PluginSignatureInfo;
}; };
type PluginBundle = { type PluginBundle = {
@@ -71,10 +74,57 @@ export async function handlePluginRoutes(
lastError: deps.pluginState.lastError[p.id] || "", lastError: deps.pluginState.lastError[p.id] || "",
autoLoad: deps.pluginState.autoLoad[p.id] === true, autoLoad: deps.pluginState.autoLoad[p.id] === true,
autoStartEvents: deps.pluginState.autoStartEvents[p.id] || [], autoStartEvents: deps.pluginState.autoStartEvents[p.id] || [],
signature: p.signature || { signed: false, trusted: false, valid: false },
})); }));
return Response.json({ plugins: enriched }); 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$/); const clientPluginsMatch = url.pathname.match(/^\/api\/clients\/(.+)\/plugins$/);
if (req.method === "GET" && clientPluginsMatch) { if (req.method === "GET" && clientPluginsMatch) {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
@@ -100,6 +150,7 @@ export async function handlePluginRoutes(
loaded: loaded.has(manifest.id), loaded: loaded.has(manifest.id),
enabled: deps.pluginState.enabled[manifest.id] !== false, enabled: deps.pluginState.enabled[manifest.id] !== false,
lastError: deps.pluginState.lastError[manifest.id] || "", lastError: deps.pluginState.lastError[manifest.id] || "",
signature: manifest.signature || { signed: false, trusted: false, valid: false },
})); }));
return Response.json({ plugins }); return Response.json({ plugins });
} }
@@ -149,12 +200,14 @@ export async function handlePluginRoutes(
return Response.json({ ok: false, error: (err as Error).message }, { status: 400 }); 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) { if (deps.pluginState.enabled[pluginId] === undefined) {
deps.pluginState.enabled[pluginId] = true; deps.pluginState.enabled[pluginId] = sigInfo.trusted === true;
await deps.savePluginState(); 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$/); const pluginEnableMatch = url.pathname.match(/^\/api\/plugins\/(.+)\/enable$/);
@@ -177,6 +230,19 @@ export async function handlePluginRoutes(
body = await req.json(); body = await req.json();
} catch {} } catch {}
const enabled = !!body.enabled; 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; deps.pluginState.enabled[pluginId] = enabled;
await deps.savePluginState(); await deps.savePluginState();
return Response.json({ ok: true, id: pluginId, enabled }); return Response.json({ ok: true, id: pluginId, enabled });
@@ -202,6 +268,14 @@ export async function handlePluginRoutes(
body = await req.json(); body = await req.json();
} catch {} } catch {}
const autoLoad = !!body.autoLoad; 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; deps.pluginState.autoLoad[pluginId] = autoLoad;
if (Array.isArray(body.autoStartEvents)) { if (Array.isArray(body.autoStartEvents)) {
@@ -474,12 +548,35 @@ export async function handlePluginRoutes(
if (deps.isPluginLoading(targetId, pluginId)) { if (deps.isPluginLoading(targetId, pluginId)) {
return Response.json({ ok: true, loading: true }); 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 { try {
const bundle = await deps.loadPluginBundle(pluginId, target.os, target.arch); const bundle = await deps.loadPluginBundle(pluginId, target.os, target.arch);
deps.markPluginLoading(targetId, pluginId); deps.markPluginLoading(targetId, pluginId);
deps.sendPluginBundle(target, bundle); deps.sendPluginBundle(target, bundle);
metrics.recordCommand("plugin_load"); metrics.recordCommand("plugin_load");
return Response.json({ ok: true }); return Response.json({ ok: true, signature: sigInfo });
} catch (err) { } catch (err) {
return Response.json({ ok: false, error: (err as Error).message }, { status: 400 }); return Response.json({ ok: false, error: (err as Error).message }, { status: 400 });
} }
+92
View File
@@ -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) - The data directory is created automatically on first use (no pre-setup needed)
- Deleting and reinstalling a plugin leaves `data/` untouched - 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
+11
View File
@@ -87,4 +87,15 @@ if errorlevel 1 (
exit /b 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% echo [ok] %ZIP_OUT%
+11
View File
@@ -67,4 +67,15 @@ else
exit 1 exit 1
fi 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}" echo "[ok] ${ZIP_OUT}"