new plugin signing and checking

This commit is contained in:
KingKDot
2026-04-14 09:56:20 -05:00
parent 60baac85cd
commit 59da653142
15 changed files with 1022 additions and 43 deletions
+3
View File
@@ -35,5 +35,8 @@
},
"enrollment": {
"requireApproval": true
},
"plugins": {
"trustedKeys": []
}
}
+2
View File
@@ -0,0 +1,2 @@
IqAQbR5RB+YOhFg3HILNtqqULAU0Z4YAbSEZXDNvDv4=
# fingerprint: c0f7b94f9678425d08d2408fd92494c419a5ea6819ee4dbcebc41d86c22c61fa
+130 -2
View File
@@ -309,6 +309,89 @@ async function loadCurrentUser() {
}
}
function showPluginConfirmModal(pluginId, clientId, sigInfo) {
document.getElementById("plugin-confirm-modal")?.remove();
const isSigned = sigInfo && sigInfo.signed && sigInfo.valid;
const statusText = isSigned
? `This plugin is signed but the signer's key is not trusted.`
: `This plugin is not signed and its origin cannot be verified.`;
const fpText = sigInfo?.fingerprint
? `Signer fingerprint: ${sigInfo.fingerprint}`
: "No signature present";
const modal = document.createElement("div");
modal.id = "plugin-confirm-modal";
modal.className = "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm";
modal.innerHTML = `
<div class="bg-slate-900 border border-slate-700 rounded-xl shadow-2xl max-w-md w-full mx-4 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full flex items-center justify-center ${isSigned ? 'bg-yellow-900/60 text-yellow-400' : 'bg-orange-900/60 text-orange-400'}">
<i class="fa-solid ${isSigned ? 'fa-shield' : 'fa-shield-halved'} text-lg"></i>
</div>
<div>
<h3 class="font-semibold text-lg">${isSigned ? 'Untrusted Plugin' : 'Unsigned Plugin'}</h3>
<p class="text-sm text-slate-400">${pluginId}</p>
</div>
</div>
<p class="text-sm text-slate-300 mb-2">${statusText}</p>
<p class="text-xs text-slate-500 font-mono mb-4">${fpText}</p>
<p class="text-sm text-slate-300 mb-3">Type <strong class="text-white">confirm</strong> below to load this plugin:</p>
<input
id="plugin-confirm-input"
type="text"
placeholder="Type confirm…"
autocomplete="off"
class="w-full px-3 py-2 rounded-lg bg-slate-950 border border-slate-700 text-slate-100 text-sm placeholder-slate-500 focus:outline-none focus:border-emerald-600 mb-4"
/>
<div class="flex gap-3 justify-end">
<button id="plugin-confirm-cancel" class="px-4 py-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-800">Cancel</button>
<button id="plugin-confirm-load" disabled class="px-4 py-2 rounded-lg bg-emerald-900/40 border border-emerald-700/60 text-emerald-100 opacity-50 cursor-not-allowed">Load Anyway</button>
</div>
</div>
`;
document.body.appendChild(modal);
const input = document.getElementById("plugin-confirm-input");
const loadBtn = document.getElementById("plugin-confirm-load");
const cancelBtn = document.getElementById("plugin-confirm-cancel");
input.addEventListener("input", () => {
const match = input.value.trim().toLowerCase() === "confirm";
loadBtn.disabled = !match;
loadBtn.classList.toggle("opacity-50", !match);
loadBtn.classList.toggle("cursor-not-allowed", !match);
loadBtn.classList.toggle("hover:bg-emerald-800/60", match);
});
cancelBtn.addEventListener("click", () => modal.remove());
modal.addEventListener("click", (e) => { if (e.target === modal) modal.remove(); });
loadBtn.addEventListener("click", async () => {
if (loadBtn.disabled) return;
loadBtn.disabled = true;
loadBtn.textContent = "Loading…";
try {
const res = await fetch(`/api/clients/${clientId}/plugins/${pluginId}/load`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirmed: true }),
});
if (!res.ok) {
const text = await res.text();
alert(`Plugin load failed: ${text}`);
} else {
window.open(`/plugins/${pluginId}?clientId=${clientId}`, "_blank", "noopener");
}
} catch {
alert("Plugin load failed");
}
modal.remove();
});
input.focus();
}
async function loadPluginsForClient(clientId) {
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");
}
+248 -33
View File
@@ -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();
+21
View File
@@ -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>
+51
View File
@@ -0,0 +1,51 @@
import { writeFileSync } from "fs";
import { resolve } from "path";
async function main() {
const args = process.argv.slice(2);
let prefix = "plugin-signing";
for (let i = 0; i < args.length; i++) {
if (args[i] === "--out" && args[i + 1]) {
prefix = args[i + 1];
i++;
}
}
const keyPair = await crypto.subtle.generateKey("Ed25519", true, [
"sign",
"verify",
]);
const privRaw = await crypto.subtle.exportKey("pkcs8", keyPair.privateKey);
const privBase64 = Buffer.from(privRaw).toString("base64");
const pubRaw = await crypto.subtle.exportKey("raw", keyPair.publicKey);
const pubBase64 = Buffer.from(pubRaw).toString("base64");
const fingerprintBuf = await crypto.subtle.digest("SHA-256", pubRaw);
const fingerprint = Buffer.from(fingerprintBuf).toString("hex");
const keyPath = resolve(prefix + ".key");
const pubPath = resolve(prefix + ".pub");
writeFileSync(keyPath, privBase64 + "\n", { mode: 0o600 });
writeFileSync(
pubPath,
`${pubBase64}\n# fingerprint: ${fingerprint}\n`,
{ mode: 0o644 },
);
console.log(`Key pair generated:`);
console.log(` Private key: ${keyPath}`);
console.log(` Public key: ${pubPath}`);
console.log(` Fingerprint: ${fingerprint}`);
console.log(``);
console.log(`Add this fingerprint to your server config to trust plugins signed with this key:`);
console.log(` "plugins": { "trustedKeys": ["${fingerprint}"] }`);
}
main().catch((err) => {
console.error("Error:", err.message || err);
process.exit(1);
});
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env bun
import { readFileSync, writeFileSync } from "fs";
import { resolve, basename } from "path";
import AdmZip from "adm-zip";
async function computeContentDigest(zip: InstanceType<typeof AdmZip>): Promise<string> {
const entries = zip.getEntries();
const fileHashes: Array<{ name: string; hash: string }> = [];
for (const entry of entries) {
if (entry.isDirectory) continue;
const name = entry.entryName;
if (name === "signature.json") continue;
const data = entry.getData();
const hashBuf = await crypto.subtle.digest("SHA-256", data);
const hash = Buffer.from(hashBuf).toString("hex");
fileHashes.push({ name, hash });
}
fileHashes.sort((a, b) => a.name.localeCompare(b.name));
return fileHashes.map((f) => `${f.name}:${f.hash}\n`).join("");
}
async function main() {
const args = process.argv.slice(2);
let keyPath = "";
let zipPath = "";
for (let i = 0; i < args.length; i++) {
if (args[i] === "--key" && args[i + 1]) {
keyPath = args[i + 1];
i++;
} else if (!args[i].startsWith("-")) {
zipPath = args[i];
}
}
if (!keyPath || !zipPath) {
console.error("Usage: bun run scripts/plugin-sign.ts --key <private.key> <plugin.zip>");
process.exit(1);
}
keyPath = resolve(keyPath);
zipPath = resolve(zipPath);
const privBase64 = readFileSync(keyPath, "utf-8").trim();
const privBytes = Buffer.from(privBase64, "base64");
const privateKey = await crypto.subtle.importKey(
"pkcs8",
privBytes,
{ name: "Ed25519" },
true,
["sign"],
);
const jwk = await crypto.subtle.exportKey("jwk", privateKey);
const { d: _privMaterial, ...publicJwk } = jwk;
const publicKey = await crypto.subtle.importKey(
"jwk",
{ ...publicJwk, key_ops: ["verify"] },
{ name: "Ed25519" },
true,
["verify"],
);
const pubRaw = await crypto.subtle.exportKey("raw", publicKey);
const pubBase64 = Buffer.from(pubRaw).toString("base64");
const fingerprintBuf = await crypto.subtle.digest("SHA-256", pubRaw);
const fingerprint = Buffer.from(fingerprintBuf).toString("hex");
const zip = new AdmZip(zipPath);
const digest = await computeContentDigest(zip);
if (!digest) {
console.error("Error: ZIP contains no signable files");
process.exit(1);
}
const digestBytes = new TextEncoder().encode(digest);
const sigBytes = await crypto.subtle.sign("Ed25519", privateKey, digestBytes);
const sigBase64 = Buffer.from(sigBytes).toString("base64");
const signatureJson = JSON.stringify(
{
algorithm: "ed25519",
publicKey: pubBase64,
signature: sigBase64,
},
null,
2,
);
try {
zip.deleteFile("signature.json");
} catch {}
zip.addFile("signature.json", Buffer.from(signatureJson, "utf-8"));
zip.writeZip(zipPath);
console.log(`Signed: ${basename(zipPath)}`);
console.log(` Signer fingerprint: ${fingerprint}`);
console.log(` Public key (base64): ${pubBase64}`);
}
main().catch((err) => {
console.error("Error:", err.message || err);
process.exit(1);
});
+46
View File
@@ -58,6 +58,9 @@ export interface Config {
appearance: {
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");
+8
View File
@@ -270,6 +270,14 @@ export type PluginManifest = {
};
};
export type PluginSignatureInfo = {
signed: boolean;
trusted: boolean;
valid: boolean;
fingerprint?: string;
algorithm?: string;
};
export type PluginEvent = {
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 });
}