mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: add plugin packing, signing, pinned trust and tmxplug bundling
This commit is contained in:
1 parent
c6a44b7ff3
commit
2ee9b3592c
25 files changed
+1764
-56
No files matched your search
@@ -12,9 +12,11 @@
|
||||
* impossible. See ARCHITECTURE.md.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import semver from "semver";
|
||||
import * as tar from "tar";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { parseManifest } from "./manifest.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
@@ -23,7 +25,9 @@ import {
|
||||
getPluginBackendEntry,
|
||||
getPluginManifestPath,
|
||||
getPluginsDir,
|
||||
getUnpackedPluginsDir,
|
||||
} from "./paths.js";
|
||||
import { requireSignedPlugins, verifyPluginArtifact } from "./trust.js";
|
||||
import {
|
||||
createPluginContext,
|
||||
createPluginHandle,
|
||||
@@ -193,16 +197,35 @@ export class PluginLoader {
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const isArtifact =
|
||||
source === "user" &&
|
||||
entry.isFile() &&
|
||||
entry.name.endsWith(".tmxplug");
|
||||
if (!entry.isDirectory() && !isArtifact) continue;
|
||||
if (entry.isDirectory() && entry.name.startsWith(".")) continue;
|
||||
|
||||
const dir = path.join(root, entry.name);
|
||||
try {
|
||||
if (isArtifact) {
|
||||
loaded.push(await this.loadArtifact(dir, bundledIds));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source === "user" && bundledIds.has(entry.name)) {
|
||||
throw new Error(
|
||||
`a bundled plugin already uses the id "${entry.name}"; a plugin in the data directory cannot replace it`,
|
||||
);
|
||||
}
|
||||
|
||||
// A folder carries no signature, so with signing required only a
|
||||
// signed .tmxplug can install a plugin. Bundled plugins are trusted
|
||||
// by shipping in the image.
|
||||
if (source === "user" && requireSignedPlugins()) {
|
||||
throw new Error(
|
||||
"TERMIX_REQUIRE_SIGNED_PLUGINS is on and a plugin folder carries no signature; install it as a signed .tmxplug",
|
||||
);
|
||||
}
|
||||
|
||||
const plugin = await this.load(dir, source);
|
||||
loaded.push(plugin);
|
||||
} catch (error) {
|
||||
@@ -218,6 +241,78 @@ export class PluginLoader {
|
||||
return loaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a user-installed <name>.tmxplug. A .sig next to it must verify
|
||||
* against a pinned key; with TERMIX_REQUIRE_SIGNED_PLUGINS=true it must
|
||||
* also exist. The archive is unpacked fresh on every load, so the file
|
||||
* stays the only source of truth.
|
||||
*/
|
||||
async loadArtifact(
|
||||
file: string,
|
||||
bundledIds: ReadonlySet<string> = new Set(),
|
||||
): Promise<LoadedPlugin> {
|
||||
const buffer = await fs.promises.readFile(file);
|
||||
const sigFile = `${file}.sig`;
|
||||
|
||||
if (fs.existsSync(sigFile)) {
|
||||
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
|
||||
const result = verifyPluginArtifact(
|
||||
buffer,
|
||||
sha256,
|
||||
await fs.promises.readFile(sigFile, "utf8"),
|
||||
);
|
||||
if (result.ok === false) {
|
||||
throw new Error(`signature check failed: ${result.reason}`);
|
||||
}
|
||||
} else if (requireSignedPlugins()) {
|
||||
throw new Error(
|
||||
`TERMIX_REQUIRE_SIGNED_PLUGINS is on and ${path.basename(file)} has no .sig next to it`,
|
||||
);
|
||||
}
|
||||
|
||||
const unpackedRoot = getUnpackedPluginsDir();
|
||||
const staging = path.join(
|
||||
unpackedRoot,
|
||||
`.staging-${crypto.randomBytes(6).toString("hex")}`,
|
||||
);
|
||||
await fs.promises.mkdir(staging, { recursive: true });
|
||||
try {
|
||||
await tar.x({
|
||||
file,
|
||||
cwd: staging,
|
||||
strict: true,
|
||||
// Plain files and folders only. tar already refuses absolute and
|
||||
// ".." paths unless preservePaths is set.
|
||||
filter: (_entryPath, entry) =>
|
||||
"type" in entry &&
|
||||
(entry.type === "File" || entry.type === "Directory"),
|
||||
});
|
||||
|
||||
const raw = JSON.parse(
|
||||
await fs.promises.readFile(getPluginManifestPath(staging), "utf8"),
|
||||
);
|
||||
const id = typeof raw?.id === "string" ? raw.id : "";
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
|
||||
throw new Error(`${path.basename(file)} has no valid manifest id`);
|
||||
}
|
||||
if (bundledIds.has(id)) {
|
||||
throw new Error(
|
||||
`a bundled plugin already uses the id "${id}"; a plugin in the data directory cannot replace it`,
|
||||
);
|
||||
}
|
||||
if (this.plugins.has(id)) {
|
||||
throw new Error(`another plugin already uses the id "${id}"`);
|
||||
}
|
||||
|
||||
const target = path.join(unpackedRoot, id);
|
||||
await fs.promises.rm(target, { recursive: true, force: true });
|
||||
await fs.promises.rename(staging, target);
|
||||
return await this.load(target, "user");
|
||||
} finally {
|
||||
await fs.promises.rm(staging, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders plugins so a dependency always activates before its dependents.
|
||||
*
|
||||
|
||||
@@ -15,6 +15,14 @@ export function getPluginsDir(): string {
|
||||
return path.join(process.env.DATA_DIR || "./db/data", "plugins");
|
||||
}
|
||||
|
||||
/**
|
||||
* Where user .tmxplug files are unpacked. A dot directory inside the user
|
||||
* plugins dir, which the loader's directory scan skips.
|
||||
*/
|
||||
export function getUnpackedPluginsDir(): string {
|
||||
return path.join(getPluginsDir(), ".unpacked");
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the plugins that ship with Termix live.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export interface TrustedPluginKey {
|
||||
/** First 16 hex chars of the sha256 of the raw public key. */
|
||||
id: string;
|
||||
/** Raw 32-byte Ed25519 public key, base64. */
|
||||
publicKey: string;
|
||||
/** The Termix release that started trusting it. */
|
||||
addedIn: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry signing keys this build trusts. Compile-time on purpose: a
|
||||
* key read from a registry index would let whoever serves the index sign
|
||||
* anything.
|
||||
*
|
||||
* Rotation: add the new key here and ship a release, start signing with it,
|
||||
* then remove the old key one release cycle later so installs that update
|
||||
* late still verify plugins signed before the switch.
|
||||
*
|
||||
* Add a key with `termix-plugin keygen` (see the Termix-Registry README).
|
||||
*/
|
||||
export const TRUSTED_PLUGIN_KEYS: readonly TrustedPluginKey[] = [];
|
||||
|
||||
export type ArtifactVerification =
|
||||
{ ok: true; keyId: string } | { ok: false; reason: string };
|
||||
|
||||
// DER prefix of an Ed25519 SubjectPublicKeyInfo; the raw key follows.
|
||||
const SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
||||
|
||||
function toPublicKey(base64: string): crypto.KeyObject | null {
|
||||
const raw = Buffer.from(base64, "base64");
|
||||
if (raw.length !== 32) return null;
|
||||
try {
|
||||
return crypto.createPublicKey({
|
||||
key: Buffer.concat([SPKI_PREFIX, raw]),
|
||||
format: "der",
|
||||
type: "spki",
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that `buffer` hashes to `sha256` (hex) and that `signature`
|
||||
* (base64 Ed25519 over the raw digest) was made by a pinned key.
|
||||
*
|
||||
* `keys` exists for tests. Production callers never pass it.
|
||||
*/
|
||||
export function verifyPluginArtifact(
|
||||
buffer: Buffer,
|
||||
sha256: string,
|
||||
signature: string,
|
||||
keys: readonly TrustedPluginKey[] = TRUSTED_PLUGIN_KEYS,
|
||||
): ArtifactVerification {
|
||||
const digest = crypto.createHash("sha256").update(buffer).digest();
|
||||
const expected = Buffer.from(sha256.trim().toLowerCase(), "hex");
|
||||
if (
|
||||
expected.length !== digest.length ||
|
||||
!crypto.timingSafeEqual(expected, digest)
|
||||
) {
|
||||
return { ok: false, reason: "sha256 does not match the file" };
|
||||
}
|
||||
|
||||
const sig = Buffer.from(signature.trim(), "base64");
|
||||
if (sig.length !== 64) {
|
||||
return { ok: false, reason: "signature is not a valid Ed25519 signature" };
|
||||
}
|
||||
if (keys.length === 0) {
|
||||
return { ok: false, reason: "this build trusts no signing keys" };
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const publicKey = toPublicKey(key.publicKey);
|
||||
if (publicKey && crypto.verify(null, digest, publicKey, sig)) {
|
||||
return { ok: true, keyId: key.id };
|
||||
}
|
||||
}
|
||||
return { ok: false, reason: "signature does not match a trusted key" };
|
||||
}
|
||||
|
||||
/** TERMIX_REQUIRE_SIGNED_PLUGINS=true blocks unsigned user plugins. */
|
||||
export function requireSignedPlugins(): boolean {
|
||||
return process.env.TERMIX_REQUIRE_SIGNED_PLUGINS === "true";
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user