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

+96 -1
View File
@@ -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.
*
+8
View File
@@ -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.
*
+86
View File
@@ -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";
}