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
@@ -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;
}