feat: add plugin packing, signing, pinned trust and tmxplug bundling

This commit is contained in:
LukeGus committed 2026-09-25 09:21:28 -05:00
1 parent c6a44b7ff3
commit 2ee9b3592c
25 files changed
+1764 -56

No files matched your search

@@ -0,0 +1,158 @@
/**
* TERMIX_REQUIRE_SIGNED_PLUGINS against the real loader and real archives.
* The pinned key list is swapped for a test key; nothing else is mocked.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import * as tar from "tar";
import { createFixturePlugin } from "./fixture-plugin.js";
import { PluginLoader } from "../../plugins/loader.js";
const testKey = vi.hoisted(() => ({
privateKey: null as crypto.KeyObject | null,
key: { id: "test", publicKey: "", addedIn: "2.9.0" },
}));
vi.mock("../../plugins/trust.js", async (importActual) => {
const actual = await importActual<typeof import("../../plugins/trust.js")>();
return {
...actual,
verifyPluginArtifact: (buffer: Buffer, sha256: string, signature: string) =>
actual.verifyPluginArtifact(buffer, sha256, signature, [testKey.key]),
};
});
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginStorageRepository: () => ({
get: async () => null,
set: async () => {},
delete: async () => false,
listKeys: async () => [],
}),
}));
{
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
testKey.privateKey = privateKey;
testKey.key.publicKey = publicKey
.export({ format: "der", type: "spki" })
.subarray(12)
.toString("base64");
}
const cleanups: Array<() => void> = [];
function tempRoot(prefix: string): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
cleanups.push(() => fs.rmSync(root, { recursive: true, force: true }));
return root;
}
/** Bundled root with one plugin, and an empty user plugins dir. */
function setup() {
const bundled = tempRoot("termix-bundled-");
const data = tempRoot("termix-data-");
const userDir = path.join(data, "plugins");
fs.mkdirSync(userDir, { recursive: true });
createFixturePlugin({ id: "bundled-one", root: bundled });
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
process.env.DATA_DIR = data;
return { userDir };
}
/** Writes <id>-1.0.0.tmxplug into `dir`, optionally with a .sig. */
async function writeArtifact(
dir: string,
id: string,
sig: "valid" | "wrong-key" | "none",
): Promise<string> {
const build = tempRoot("termix-build-");
createFixturePlugin({ id, root: build });
const file = path.join(dir, `${id}-1.0.0.tmxplug`);
await tar.c({ gzip: true, file, cwd: path.join(build, id) }, [
"manifest.json",
"backend",
]);
if (sig !== "none") {
const signer =
sig === "valid"
? testKey.privateKey
: crypto.generateKeyPairSync("ed25519").privateKey;
const digest = crypto
.createHash("sha256")
.update(fs.readFileSync(file))
.digest();
fs.writeFileSync(
`${file}.sig`,
crypto.sign(null, digest, signer).toString("base64"),
);
}
return file;
}
afterEach(() => {
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
delete process.env.DATA_DIR;
delete process.env.TERMIX_REQUIRE_SIGNED_PLUGINS;
while (cleanups.length) cleanups.pop()?.();
});
async function loadedIds(): Promise<string[]> {
const loader = new PluginLoader();
const loaded = await loader.loadAll();
return loaded.map((plugin) => plugin.id).sort();
}
describe("signed plugins gate off", () => {
it("loads a user folder, an unsigned archive and a signed archive", async () => {
const { userDir } = setup();
createFixturePlugin({ id: "folder-one", root: userDir });
await writeArtifact(userDir, "unsigned-one", "none");
await writeArtifact(userDir, "signed-one", "valid");
expect(await loadedIds()).toEqual([
"bundled-one",
"folder-one",
"signed-one",
"unsigned-one",
]);
expect(
fs.existsSync(
path.join(userDir, ".unpacked", "signed-one", "manifest.json"),
),
).toBe(true);
});
it("still blocks an archive whose .sig does not verify", async () => {
const { userDir } = setup();
await writeArtifact(userDir, "forged-one", "wrong-key");
expect(await loadedIds()).toEqual(["bundled-one"]);
});
});
describe("signed plugins gate on", () => {
it("blocks user folders and unsigned archives, keeps bundled plugins", async () => {
const { userDir } = setup();
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true";
createFixturePlugin({ id: "folder-one", root: userDir });
await writeArtifact(userDir, "unsigned-one", "none");
await writeArtifact(userDir, "forged-one", "wrong-key");
await writeArtifact(userDir, "signed-one", "valid");
expect(await loadedIds()).toEqual(["bundled-one", "signed-one"]);
});
it("refuses a signed archive that takes a bundled plugin's id", async () => {
const { userDir } = setup();
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true";
await writeArtifact(userDir, "bundled-one", "valid");
const loader = new PluginLoader();
const loaded = await loader.loadAll();
expect(loaded.map((plugin) => plugin.id)).toEqual(["bundled-one"]);
expect(loader.get("bundled-one")?.source).toBe("bundled");
});
});
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import crypto from "node:crypto";
import {
requireSignedPlugins,
verifyPluginArtifact,
type TrustedPluginKey,
} from "../../plugins/trust.js";
function keyPair(): { key: TrustedPluginKey; privateKey: crypto.KeyObject } {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const raw = publicKey
.export({ format: "der", type: "spki" })
.subarray(12)
.toString("base64");
return { key: { id: "test", publicKey: raw, addedIn: "2.9.0" }, privateKey };
}
function signed(buffer: Buffer, privateKey: crypto.KeyObject) {
const digest = crypto.createHash("sha256").update(buffer).digest();
return {
sha256: digest.toString("hex"),
signature: crypto.sign(null, digest, privateKey).toString("base64"),
};
}
describe("verifyPluginArtifact", () => {
const artifact = Buffer.from("a plugin archive");
it("accepts a file signed by a pinned key", () => {
const { key, privateKey } = keyPair();
const { sha256, signature } = signed(artifact, privateKey);
expect(verifyPluginArtifact(artifact, sha256, signature, [key])).toEqual({
ok: true,
keyId: "test",
});
});
it("accepts a signature from any of several pinned keys", () => {
const old = keyPair();
const current = keyPair();
const { sha256, signature } = signed(artifact, old.privateKey);
const result = verifyPluginArtifact(artifact, sha256, signature, [
current.key,
{ ...old.key, id: "old" },
]);
expect(result).toEqual({ ok: true, keyId: "old" });
});
it("refuses a tampered file", () => {
const { key, privateKey } = keyPair();
const { sha256, signature } = signed(artifact, privateKey);
const tampered = Buffer.from("a plugin archivE");
expect(verifyPluginArtifact(tampered, sha256, signature, [key])).toEqual({
ok: false,
reason: "sha256 does not match the file",
});
});
it("refuses a tampered file even when the sha256 matches it", () => {
const { key, privateKey } = keyPair();
const { signature } = signed(artifact, privateKey);
const tampered = Buffer.from("something else");
const sha256 = crypto.createHash("sha256").update(tampered).digest("hex");
expect(verifyPluginArtifact(tampered, sha256, signature, [key])).toEqual({
ok: false,
reason: "signature does not match a trusted key",
});
});
it("refuses a signature from a key that is not pinned", () => {
const pinned = keyPair();
const other = keyPair();
const { sha256, signature } = signed(artifact, other.privateKey);
expect(
verifyPluginArtifact(artifact, sha256, signature, [pinned.key]).ok,
).toBe(false);
});
it("refuses garbage and an empty key list", () => {
const { key, privateKey } = keyPair();
const { sha256, signature } = signed(artifact, privateKey);
expect(verifyPluginArtifact(artifact, sha256, "bm9wZQ==", [key]).ok).toBe(
false,
);
expect(verifyPluginArtifact(artifact, "zz", signature, [key]).ok).toBe(
false,
);
expect(verifyPluginArtifact(artifact, sha256, signature, []).ok).toBe(
false,
);
});
it("trusts no key by default until one is pinned", () => {
const { privateKey } = keyPair();
const { sha256, signature } = signed(artifact, privateKey);
expect(verifyPluginArtifact(artifact, sha256, signature).ok).toBe(false);
});
});
describe("requireSignedPlugins", () => {
it("is on only for the exact value true", () => {
const before = process.env.TERMIX_REQUIRE_SIGNED_PLUGINS;
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true";
expect(requireSignedPlugins()).toBe(true);
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "1";
expect(requireSignedPlugins()).toBe(false);
if (before === undefined) delete process.env.TERMIX_REQUIRE_SIGNED_PLUGINS;
else process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = before;
});
});