new plugin signing and checking
This commit is contained in:
@@ -35,5 +35,8 @@
|
|||||||
},
|
},
|
||||||
"enrollment": {
|
"enrollment": {
|
||||||
"requireApproval": true
|
"requireApproval": true
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"trustedKeys": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
IqAQbR5RB+YOhFg3HILNtqqULAU0Z4YAbSEZXDNvDv4=
|
||||||
|
# fingerprint: c0f7b94f9678425d08d2408fd92494c419a5ea6819ee4dbcebc41d86c22c61fa
|
||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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%
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
Reference in New Issue
Block a user