new plugin signing and checking
This commit is contained in:
@@ -35,5 +35,8 @@
|
||||
},
|
||||
"enrollment": {
|
||||
"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) {
|
||||
const section = document.getElementById("plugin-section");
|
||||
const container = document.getElementById("plugin-menu");
|
||||
@@ -336,11 +419,38 @@ async function loadPluginsForClient(clientId) {
|
||||
if (plugin.lastError) {
|
||||
btn.title = `Last error: ${plugin.lastError}`;
|
||||
}
|
||||
|
||||
const sig = plugin.signature;
|
||||
if (sig && sig.signed && !sig.valid) {
|
||||
btn.disabled = true;
|
||||
btn.classList.add("opacity-50", "cursor-not-allowed");
|
||||
btn.title = "Plugin signature is invalid — cannot load";
|
||||
}
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "flex items-center gap-1";
|
||||
const labelIcon = document.createElement("i");
|
||||
labelIcon.className = "fa-solid fa-puzzle-piece";
|
||||
label.appendChild(labelIcon);
|
||||
label.append(` ${plugin.name || plugin.id}`);
|
||||
|
||||
if (sig) {
|
||||
const trustIcon = document.createElement("i");
|
||||
if (sig.signed && !sig.valid) {
|
||||
trustIcon.className = "fa-solid fa-shield-xmark text-red-400 text-xs ml-1";
|
||||
trustIcon.title = "Invalid signature";
|
||||
} else if (sig.signed && sig.valid && sig.trusted) {
|
||||
trustIcon.className = "fa-solid fa-shield-check text-emerald-400 text-xs ml-1";
|
||||
trustIcon.title = "Trusted";
|
||||
} else if (sig.signed && sig.valid && !sig.trusted) {
|
||||
trustIcon.className = "fa-solid fa-shield text-yellow-400 text-xs ml-1";
|
||||
trustIcon.title = "Signed but untrusted";
|
||||
} else {
|
||||
trustIcon.className = "fa-solid fa-shield-halved text-orange-400 text-xs ml-1";
|
||||
trustIcon.title = "Unsigned";
|
||||
}
|
||||
label.appendChild(trustIcon);
|
||||
}
|
||||
const badge = document.createElement("span");
|
||||
badge.className =
|
||||
"text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full border" +
|
||||
@@ -777,17 +887,35 @@ menu.addEventListener("click", async (e) => {
|
||||
}
|
||||
const pluginId = target.dataset.plugin;
|
||||
if (pluginId) {
|
||||
const savedClientId = contextCard;
|
||||
try {
|
||||
const res = await fetch(`/api/clients/${contextCard}/plugins/${pluginId}/load`, {
|
||||
const res = await fetch(`/api/clients/${savedClientId}/plugins/${pluginId}/load`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (res.status === 428) {
|
||||
const data = await res.json();
|
||||
closeMenu(clearContext);
|
||||
showPluginConfirmModal(pluginId, savedClientId, data.signature);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status === 403) {
|
||||
const data = await res.json();
|
||||
alert(data.error || "Plugin load blocked — invalid signature");
|
||||
closeMenu(clearContext);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
alert(`Plugin load failed: ${text}`);
|
||||
closeMenu(clearContext);
|
||||
return;
|
||||
}
|
||||
window.open(`/plugins/${pluginId}?clientId=${contextCard}`, "_blank", "noopener");
|
||||
window.open(`/plugins/${pluginId}?clientId=${savedClientId}`, "_blank", "noopener");
|
||||
} catch (err) {
|
||||
alert("Plugin load failed");
|
||||
}
|
||||
|
||||
@@ -88,6 +88,14 @@ async function fetchPlugins() {
|
||||
return Array.isArray(data.plugins) ? data.plugins : [];
|
||||
}
|
||||
|
||||
function getTrustBadge(sig) {
|
||||
if (!sig) return { icon: "fa-shield-halved", color: "text-orange-400 border-orange-600 bg-orange-900/30", label: "Unsigned", tooltip: "This plugin is not signed" };
|
||||
if (sig.signed && !sig.valid) return { icon: "fa-shield-xmark", color: "text-red-400 border-red-600 bg-red-900/30", label: "Invalid", tooltip: "Signature verification failed — plugin may be tampered" };
|
||||
if (sig.signed && sig.valid && sig.trusted) return { icon: "fa-shield-check", color: "text-emerald-400 border-emerald-600 bg-emerald-900/30", label: "Trusted", tooltip: `Signed by trusted key: ${sig.fingerprint || "unknown"}` };
|
||||
if (sig.signed && sig.valid && !sig.trusted) return { icon: "fa-shield", color: "text-yellow-400 border-yellow-600 bg-yellow-900/30", label: "Untrusted", tooltip: `Signed but key not trusted: ${sig.fingerprint || "unknown"}` };
|
||||
return { icon: "fa-shield-halved", color: "text-orange-400 border-orange-600 bg-orange-900/30", label: "Unsigned", tooltip: "This plugin is not signed" };
|
||||
}
|
||||
|
||||
function renderPlugins(plugins) {
|
||||
pluginList.innerHTML = "";
|
||||
if (!plugins.length) {
|
||||
@@ -100,13 +108,34 @@ function renderPlugins(plugins) {
|
||||
card.className =
|
||||
"rounded-xl border border-slate-800 bg-slate-950/50 px-4 py-3 flex items-center justify-between";
|
||||
const meta = document.createElement("div");
|
||||
const title = document.createElement("div");
|
||||
const titleRow = document.createElement("div");
|
||||
titleRow.className = "flex items-center gap-2";
|
||||
const title = document.createElement("span");
|
||||
title.className = "font-semibold";
|
||||
title.textContent = plugin.name || plugin.id;
|
||||
titleRow.appendChild(title);
|
||||
|
||||
// Trust badge
|
||||
const badge = getTrustBadge(plugin.signature);
|
||||
const trustBadge = document.createElement("span");
|
||||
trustBadge.className = `inline-flex items-center gap-1 text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full border ${badge.color}`;
|
||||
trustBadge.innerHTML = `<i class="fa-solid ${badge.icon}"></i> ${badge.label}`;
|
||||
trustBadge.title = badge.tooltip;
|
||||
titleRow.appendChild(trustBadge);
|
||||
|
||||
const subtitle = document.createElement("div");
|
||||
subtitle.className = "text-sm text-slate-400";
|
||||
subtitle.textContent = `${plugin.id}${plugin.version ? ` • v${plugin.version}` : ""}`;
|
||||
meta.appendChild(title);
|
||||
|
||||
if (plugin.signature?.fingerprint) {
|
||||
const fpSpan = document.createElement("span");
|
||||
fpSpan.className = "ml-2 text-xs text-slate-500 font-mono";
|
||||
fpSpan.textContent = `${plugin.signature.fingerprint.slice(0, 16)}…`;
|
||||
fpSpan.title = `Signer fingerprint: ${plugin.signature.fingerprint}`;
|
||||
subtitle.appendChild(fpSpan);
|
||||
}
|
||||
|
||||
meta.appendChild(titleRow);
|
||||
meta.appendChild(subtitle);
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "flex items-center gap-2";
|
||||
@@ -121,47 +150,65 @@ function renderPlugins(plugins) {
|
||||
? '<i class="fa-solid fa-toggle-on"></i> Enabled'
|
||||
: '<i class="fa-solid fa-toggle-off"></i> Disabled';
|
||||
toggle.addEventListener("click", async () => {
|
||||
const res = await fetch(`/api/plugins/${plugin.id}/enable`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: !plugin.enabled }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
setStatus(`Enable failed: ${text}`, true);
|
||||
return;
|
||||
const wantEnabled = !plugin.enabled;
|
||||
try {
|
||||
const res = await fetch(`/api/plugins/${plugin.id}/enable`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: wantEnabled }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
if (data && data.error === "confirmation_required") {
|
||||
showPluginEnableConfirmModal(plugin, data.signature);
|
||||
return;
|
||||
}
|
||||
setStatus(`Enable failed: ${data?.error || res.statusText}`, true);
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setStatus("Enable failed", true);
|
||||
}
|
||||
await refresh();
|
||||
});
|
||||
|
||||
const autoLoadBtn = document.createElement("button");
|
||||
const autoLoadDisabled = !plugin.enabled;
|
||||
autoLoadBtn.className =
|
||||
"inline-flex items-center gap-2 px-3 py-2 rounded-lg border" +
|
||||
(plugin.autoLoad
|
||||
? " border-amber-600 text-amber-200 bg-amber-900/40"
|
||||
: " border-slate-600 text-slate-300 bg-slate-800/60");
|
||||
(autoLoadDisabled
|
||||
? " border-slate-700 text-slate-500 bg-slate-900/40 cursor-not-allowed opacity-50"
|
||||
: plugin.autoLoad
|
||||
? " border-amber-600 text-amber-200 bg-amber-900/40"
|
||||
: " border-slate-600 text-slate-300 bg-slate-800/60");
|
||||
autoLoadBtn.innerHTML = plugin.autoLoad
|
||||
? '<i class="fa-solid fa-bolt"></i> Auto-load'
|
||||
: '<i class="fa-solid fa-bolt-lightning"></i> Auto-load off';
|
||||
autoLoadBtn.title = plugin.autoLoad
|
||||
? "Plugin will auto-load on all new client connections. Click to disable."
|
||||
: "Click to auto-load this plugin on all new client connections.";
|
||||
autoLoadBtn.addEventListener("click", async () => {
|
||||
const res = await fetch(`/api/plugins/${plugin.id}/autoload`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
autoLoad: !plugin.autoLoad,
|
||||
autoStartEvents: plugin.autoStartEvents || [],
|
||||
}),
|
||||
autoLoadBtn.title = autoLoadDisabled
|
||||
? "Plugin must be enabled before auto-load can be turned on"
|
||||
: plugin.autoLoad
|
||||
? "Plugin will auto-load on all new client connections. Click to disable."
|
||||
: "Click to auto-load this plugin on all new client connections.";
|
||||
if (autoLoadDisabled) {
|
||||
autoLoadBtn.disabled = true;
|
||||
} else {
|
||||
autoLoadBtn.addEventListener("click", async () => {
|
||||
const res = await fetch(`/api/plugins/${plugin.id}/autoload`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
autoLoad: !plugin.autoLoad,
|
||||
autoStartEvents: plugin.autoStartEvents || [],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
setStatus(`Auto-load toggle failed: ${text}`, true);
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
setStatus(`Auto-load toggle failed: ${text}`, true);
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
});
|
||||
}
|
||||
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.className =
|
||||
@@ -194,6 +241,80 @@ function renderPlugins(plugins) {
|
||||
}
|
||||
}
|
||||
|
||||
function showPluginEnableConfirmModal(plugin, sigInfo) {
|
||||
document.getElementById("plugin-enable-confirm-modal")?.remove();
|
||||
|
||||
const sig = sigInfo || {};
|
||||
let statusText = "This plugin is unsigned.";
|
||||
let statusColor = "text-orange-400";
|
||||
if (sig.signed && sig.valid && !sig.trusted) {
|
||||
statusText = "This plugin is signed but the signing key is not trusted.";
|
||||
statusColor = "text-yellow-400";
|
||||
} else if (sig.signed && !sig.valid) {
|
||||
statusText = "This plugin has an invalid signature and may have been tampered with.";
|
||||
statusColor = "text-red-400";
|
||||
}
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.id = "plugin-enable-confirm-modal";
|
||||
overlay.className = "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm";
|
||||
overlay.innerHTML = `
|
||||
<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() {
|
||||
const plugins = await fetchPlugins();
|
||||
renderPlugins(plugins);
|
||||
@@ -237,5 +358,99 @@ fileInput?.addEventListener("change", (e) => {
|
||||
|
||||
refreshBtn?.addEventListener("click", refresh);
|
||||
|
||||
const trustedKeysSection = document.getElementById("trusted-keys-section");
|
||||
const trustedKeysList = document.getElementById("trusted-keys-list");
|
||||
const addKeyBtn = document.getElementById("add-trusted-key-btn");
|
||||
const newKeyInput = document.getElementById("new-trusted-key-input");
|
||||
|
||||
let _builtinKeys = [];
|
||||
|
||||
async function fetchTrustedKeys() {
|
||||
try {
|
||||
const res = await fetch("/api/plugins/trusted-keys");
|
||||
if (!res.ok) {
|
||||
if (res.status === 403) {
|
||||
if (trustedKeysSection) trustedKeysSection.classList.add("hidden");
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
const data = await res.json();
|
||||
if (trustedKeysSection) trustedKeysSection.classList.remove("hidden");
|
||||
_builtinKeys = Array.isArray(data.builtinKeys) ? data.builtinKeys : [];
|
||||
return Array.isArray(data.trustedKeys) ? data.trustedKeys : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function renderTrustedKeys(keys) {
|
||||
if (!trustedKeysList) return;
|
||||
trustedKeysList.innerHTML = "";
|
||||
if (!keys.length) {
|
||||
trustedKeysList.innerHTML = '<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();
|
||||
refresh();
|
||||
refreshTrustedKeys();
|
||||
|
||||
@@ -65,6 +65,27 @@
|
||||
</div>
|
||||
<div id="plugin-list" class="mt-4 grid gap-3"></div>
|
||||
</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>
|
||||
</main>
|
||||
<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: {
|
||||
customCSS: string;
|
||||
};
|
||||
plugins: {
|
||||
trustedKeys: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: Config = {
|
||||
@@ -114,6 +117,9 @@ const DEFAULT_CONFIG: Config = {
|
||||
appearance: {
|
||||
customCSS: "",
|
||||
},
|
||||
plugins: {
|
||||
trustedKeys: [],
|
||||
},
|
||||
};
|
||||
|
||||
type SaveSecrets = {
|
||||
@@ -432,6 +438,15 @@ export function loadConfig(): Config {
|
||||
appearance: {
|
||||
customCSS: fileConfig.appearance?.customCSS || DEFAULT_CONFIG.appearance.customCSS,
|
||||
},
|
||||
plugins: {
|
||||
trustedKeys: (() => {
|
||||
const envKeys = process.env.TRUSTED_PLUGIN_KEYS;
|
||||
if (envKeys) {
|
||||
return envKeys.split(",").map((k) => k.trim()).filter(Boolean);
|
||||
}
|
||||
return fileConfig.plugins?.trustedKeys || DEFAULT_CONFIG.plugins.trustedKeys;
|
||||
})(),
|
||||
},
|
||||
};
|
||||
|
||||
if (saveChanged) {
|
||||
@@ -616,6 +631,31 @@ export async function updateAppearanceConfig(customCSS: string): Promise<Config[
|
||||
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> {
|
||||
const config = getConfig();
|
||||
return {
|
||||
@@ -632,6 +672,7 @@ export function getExportableConfig(serverVersion: string): Record<string, unkno
|
||||
tls: config.tls,
|
||||
enrollment: config.enrollment,
|
||||
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") {
|
||||
const dataDir = ensureDataDir();
|
||||
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 = {
|
||||
type: "plugin_event";
|
||||
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 type { ClientInfo } from "../types";
|
||||
import { logger } from "../logger";
|
||||
import { encodeMessage, type PluginManifest } from "../protocol";
|
||||
import { encodeMessage, type PluginManifest, type PluginSignatureInfo } from "../protocol";
|
||||
import { verifyPluginSignature, getOrVerifySignature } from "./plugin-signature";
|
||||
|
||||
export type PluginState = {
|
||||
enabled: Record<string, boolean>;
|
||||
@@ -126,6 +127,14 @@ export async function ensurePluginExtracted(
|
||||
},
|
||||
};
|
||||
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
|
||||
try {
|
||||
const sigInfo = await verifyPluginSignature(zipPath);
|
||||
const sigInfoPath = path.join(pluginDir, "signature-info.json");
|
||||
await fs.writeFile(sigInfoPath, JSON.stringify(sigInfo, null, 2));
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin] failed to verify signature for ${safeId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncPluginBundles(
|
||||
@@ -146,16 +155,20 @@ export async function syncPluginBundles(
|
||||
}
|
||||
}
|
||||
|
||||
export type PluginManifestWithSignature = PluginManifest & {
|
||||
signature?: PluginSignatureInfo;
|
||||
};
|
||||
|
||||
export async function listPluginManifests(
|
||||
pluginRoot: string,
|
||||
pluginState: PluginState,
|
||||
saveState: () => Promise<void>,
|
||||
ensureExtracted: (pluginId: string) => Promise<void>,
|
||||
): Promise<PluginManifest[]> {
|
||||
): Promise<PluginManifestWithSignature[]> {
|
||||
try {
|
||||
await syncPluginBundles(pluginRoot, ensureExtracted);
|
||||
const entries = await fs.readdir(pluginRoot, { withFileTypes: true });
|
||||
const manifests: PluginManifest[] = [];
|
||||
const manifests: PluginManifestWithSignature[] = [];
|
||||
for (const ent of entries) {
|
||||
if (!ent.isDirectory()) continue;
|
||||
const manifestPath = path.join(pluginRoot, ent.name, "manifest.json");
|
||||
@@ -167,7 +180,8 @@ export async function listPluginManifests(
|
||||
if (pluginState.enabled[id] === undefined) {
|
||||
pluginState.enabled[id] = true;
|
||||
}
|
||||
manifests.push({ ...manifest, id, name });
|
||||
const sigInfo = await getOrVerifySignature(pluginRoot, id);
|
||||
manifests.push({ ...manifest, id, name, signature: sigInfo });
|
||||
} catch {}
|
||||
}
|
||||
await saveState();
|
||||
|
||||
@@ -6,11 +6,14 @@ import { requirePermission } from "../../rbac";
|
||||
import { canUserAccessClient } from "../../users";
|
||||
import * as clientManager from "../../clientManager";
|
||||
import { metrics } from "../../metrics";
|
||||
import { encodeMessage } from "../../protocol";
|
||||
import { encodeMessage, type PluginSignatureInfo } from "../../protocol";
|
||||
import { getConfig, updatePluginsConfig } from "../../config";
|
||||
import { getOrVerifySignature, BUILTIN_TRUSTED_KEYS } from "../plugin-signature";
|
||||
|
||||
type PluginManifest = {
|
||||
id: string;
|
||||
name: string;
|
||||
signature?: PluginSignatureInfo;
|
||||
};
|
||||
|
||||
type PluginBundle = {
|
||||
@@ -71,10 +74,57 @@ export async function handlePluginRoutes(
|
||||
lastError: deps.pluginState.lastError[p.id] || "",
|
||||
autoLoad: deps.pluginState.autoLoad[p.id] === true,
|
||||
autoStartEvents: deps.pluginState.autoStartEvents[p.id] || [],
|
||||
signature: p.signature || { signed: false, trusted: false, valid: false },
|
||||
}));
|
||||
return Response.json({ plugins: enriched });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/plugins/trusted-keys") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
if (user.role !== "admin") {
|
||||
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||
}
|
||||
const config = getConfig();
|
||||
const allKeys = Array.from(new Set([...BUILTIN_TRUSTED_KEYS, ...config.plugins.trustedKeys]));
|
||||
return Response.json({ trustedKeys: allKeys, builtinKeys: BUILTIN_TRUSTED_KEYS });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/plugins/trusted-keys") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
if (user.role !== "admin") {
|
||||
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||
}
|
||||
let body: any = {};
|
||||
try { body = await req.json(); } catch {}
|
||||
const fingerprint = typeof body.fingerprint === "string" ? body.fingerprint.trim().toLowerCase() : "";
|
||||
if (!fingerprint || !/^[a-f0-9]{64}$/.test(fingerprint)) {
|
||||
return Response.json({ ok: false, error: "Invalid fingerprint (expected 64-char hex SHA-256)" }, { status: 400 });
|
||||
}
|
||||
const config = getConfig();
|
||||
const keys = [...config.plugins.trustedKeys];
|
||||
if (!keys.includes(fingerprint)) {
|
||||
keys.push(fingerprint);
|
||||
await updatePluginsConfig({ trustedKeys: keys });
|
||||
}
|
||||
return Response.json({ ok: true, trustedKeys: keys });
|
||||
}
|
||||
|
||||
const trustedKeyDeleteMatch = url.pathname.match(/^\/api\/plugins\/trusted-keys\/([a-f0-9]{64})$/);
|
||||
if (req.method === "DELETE" && trustedKeyDeleteMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
if (user.role !== "admin") {
|
||||
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||
}
|
||||
const fingerprint = trustedKeyDeleteMatch[1].toLowerCase();
|
||||
const config = getConfig();
|
||||
const keys = config.plugins.trustedKeys.filter((k) => k.toLowerCase() !== fingerprint);
|
||||
await updatePluginsConfig({ trustedKeys: keys });
|
||||
return Response.json({ ok: true, trustedKeys: keys });
|
||||
}
|
||||
|
||||
const clientPluginsMatch = url.pathname.match(/^\/api\/clients\/(.+)\/plugins$/);
|
||||
if (req.method === "GET" && clientPluginsMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
@@ -100,6 +150,7 @@ export async function handlePluginRoutes(
|
||||
loaded: loaded.has(manifest.id),
|
||||
enabled: deps.pluginState.enabled[manifest.id] !== false,
|
||||
lastError: deps.pluginState.lastError[manifest.id] || "",
|
||||
signature: manifest.signature || { signed: false, trusted: false, valid: false },
|
||||
}));
|
||||
return Response.json({ plugins });
|
||||
}
|
||||
@@ -149,12 +200,14 @@ export async function handlePluginRoutes(
|
||||
return Response.json({ ok: false, error: (err as Error).message }, { status: 400 });
|
||||
}
|
||||
|
||||
const sigInfo = await getOrVerifySignature(deps.PLUGIN_ROOT, pluginId);
|
||||
|
||||
if (deps.pluginState.enabled[pluginId] === undefined) {
|
||||
deps.pluginState.enabled[pluginId] = true;
|
||||
deps.pluginState.enabled[pluginId] = sigInfo.trusted === true;
|
||||
await deps.savePluginState();
|
||||
}
|
||||
|
||||
return Response.json({ ok: true, id: pluginId });
|
||||
return Response.json({ ok: true, id: pluginId, enabled: deps.pluginState.enabled[pluginId], signature: sigInfo });
|
||||
}
|
||||
|
||||
const pluginEnableMatch = url.pathname.match(/^\/api\/plugins\/(.+)\/enable$/);
|
||||
@@ -177,6 +230,19 @@ export async function handlePluginRoutes(
|
||||
body = await req.json();
|
||||
} catch {}
|
||||
const enabled = !!body.enabled;
|
||||
|
||||
if (enabled) {
|
||||
const sigInfo = await getOrVerifySignature(deps.PLUGIN_ROOT, pluginId);
|
||||
if (!sigInfo.trusted) {
|
||||
if (body.confirmed !== true) {
|
||||
return Response.json(
|
||||
{ ok: false, error: "confirmation_required", signature: sigInfo },
|
||||
{ status: 428 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deps.pluginState.enabled[pluginId] = enabled;
|
||||
await deps.savePluginState();
|
||||
return Response.json({ ok: true, id: pluginId, enabled });
|
||||
@@ -202,6 +268,14 @@ export async function handlePluginRoutes(
|
||||
body = await req.json();
|
||||
} catch {}
|
||||
const autoLoad = !!body.autoLoad;
|
||||
|
||||
if (autoLoad && deps.pluginState.enabled[pluginId] === false) {
|
||||
return Response.json(
|
||||
{ ok: false, error: "Plugin must be enabled before auto-load can be turned on" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
deps.pluginState.autoLoad[pluginId] = autoLoad;
|
||||
|
||||
if (Array.isArray(body.autoStartEvents)) {
|
||||
@@ -474,12 +548,35 @@ export async function handlePluginRoutes(
|
||||
if (deps.isPluginLoading(targetId, pluginId)) {
|
||||
return Response.json({ ok: true, loading: true });
|
||||
}
|
||||
|
||||
const sigInfo = await getOrVerifySignature(deps.PLUGIN_ROOT, pluginId);
|
||||
|
||||
if (sigInfo.signed && !sigInfo.valid) {
|
||||
return Response.json(
|
||||
{ ok: false, error: "Plugin signature is invalid — the plugin may have been tampered with", signature: sigInfo },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!sigInfo.trusted) {
|
||||
let body: any = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {}
|
||||
if (body.confirmed !== true) {
|
||||
return Response.json(
|
||||
{ ok: false, error: "confirmation_required", signature: sigInfo },
|
||||
{ status: 428 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const bundle = await deps.loadPluginBundle(pluginId, target.os, target.arch);
|
||||
deps.markPluginLoading(targetId, pluginId);
|
||||
deps.sendPluginBundle(target, bundle);
|
||||
metrics.recordCommand("plugin_load");
|
||||
return Response.json({ ok: true });
|
||||
return Response.json({ ok: true, signature: sigInfo });
|
||||
} catch (err) {
|
||||
return Response.json({ ok: false, error: (err as Error).message }, { status: 400 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user