diff --git a/.gitignore b/.gitignore index 4f15e17dd..458fe27ac 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ coverage # Plugin build output. termix-plugin build writes it, the packaged server # gets its copy under dist/plugins. /plugins/*/dist/ +*.tmxplug +*.tmxplug.sig +termix-plugin-signing.key /src/**/*.js .vscode/* diff --git a/docker/bundled-plugins.json b/docker/bundled-plugins.json new file mode 100644 index 000000000..11b2d5dfc --- /dev/null +++ b/docker/bundled-plugins.json @@ -0,0 +1,132 @@ +{ + "plugins": [ + { + "id": "acme-ssl", + "source": "workspace" + }, + { + "id": "ai", + "source": "workspace" + }, + { + "id": "automations", + "source": "workspace" + }, + { + "id": "docker", + "source": "workspace" + }, + { + "id": "file-manager", + "source": "workspace" + }, + { + "id": "fleets", + "source": "workspace" + }, + { + "id": "homepage", + "source": "workspace" + }, + { + "id": "host-metrics", + "source": "workspace" + }, + { + "id": "ldap", + "source": "workspace" + }, + { + "id": "network-topology", + "source": "workspace" + }, + { + "id": "opkssh", + "source": "workspace" + }, + { + "id": "proxmox", + "source": "workspace" + }, + { + "id": "remote-desktop", + "source": "workspace" + }, + { + "id": "secret-sources", + "source": "workspace" + }, + { + "id": "serial", + "source": "workspace" + }, + { + "id": "session-recording", + "source": "workspace" + }, + { + "id": "session-sharing", + "source": "workspace" + }, + { + "id": "snippets", + "source": "workspace" + }, + { + "id": "ssh-terminal", + "source": "workspace" + }, + { + "id": "sso", + "source": "workspace" + }, + { + "id": "step-ca", + "source": "workspace" + }, + { + "id": "tailscale", + "source": "workspace" + }, + { + "id": "termix-identity", + "source": "workspace" + }, + { + "id": "tmux-monitor", + "source": "workspace" + }, + { + "id": "totp", + "source": "workspace" + }, + { + "id": "tunnels", + "source": "workspace" + }, + { + "id": "vault", + "source": "workspace" + }, + { + "id": "wake-on-lan", + "source": "workspace" + }, + { + "id": "warpgate", + "source": "workspace" + }, + { + "id": "web-endpoint", + "source": "workspace" + }, + { + "id": "webauthn", + "source": "workspace" + }, + { + "id": "workspaces", + "source": "workspace" + } + ] +} diff --git a/package-lock.json b/package-lock.json index 1525ca2b8..69465aaf7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "sharp": "^0.35.4", "socks": "^2.8.10", "ssh2": "^1.17.0", + "tar": "^7.5.22", "undici": "^8.10.2", "ws": "^8.21.3" }, @@ -18884,16 +18885,31 @@ "bin": { "termix-plugin": "cli/index.mjs" }, + "engines": { + "node": ">=22" + }, "peerDependencies": { + "better-sqlite3": "*", "drizzle-orm": "*", - "ssh2": "*" + "react": "*", + "ssh2": "*", + "vitest": "*" }, "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, "drizzle-orm": { "optional": true }, + "react": { + "optional": true + }, "ssh2": { "optional": true + }, + "vitest": { + "optional": true } } }, diff --git a/package.json b/package.json index 96cad6787..cb13d5a48 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "sharp": "^0.35.4", "socks": "^2.8.10", "ssh2": "^1.17.0", + "tar": "^7.5.22", "undici": "^8.10.2", "ws": "^8.21.3" }, diff --git a/packages/plugin-sdk/ARCHITECTURE.md b/packages/plugin-sdk/ARCHITECTURE.md index 016db9380..688b1f79e 100644 --- a/packages/plugin-sdk/ARCHITECTURE.md +++ b/packages/plugin-sdk/ARCHITECTURE.md @@ -1639,10 +1639,44 @@ plugins// src/backend/tables.ts defineTable() definitions tests/backend/ vitest, node tests/frontend/ vitest, jsdom - README.md CHANGELOG.md + README.md CHANGELOG.md icon.svg dist/ build output, gitignored ``` +#### Which plugins ship, and from where + +`docker/bundled-plugins.json` lists every plugin the image ships and where +each comes from. `npm run build:plugins` (`scripts/build-plugins.cjs`) reads +it and stages each one in `dist/plugins//`: + +```json +{ "id": "tunnels", "source": "workspace" } +{ "id": "tunnels", "source": "tmxplug", + "url": "https://github.com/Termix-SSH/termix-plugin-tunnels/releases/download/v1.0.0/tunnels-1.0.0.tmxplug", + "sha256": "<64 hex>" } +``` + +A `workspace` entry is built from `plugins//`. A `tmxplug` entry is +downloaded (cached under `node_modules/.cache/termix-bundled-plugins/`) or +read from a local `path`, refused unless its sha256 matches the one pinned in +the file, and unpacked. The build fails on a `plugins/` folder nobody listed, +a workspace entry with no folder, or a tmxplug entry whose folder still +exists, so the two can never both ship. + +Switching a plugin over once its own repo exists: + +1. Tag a release in the plugin's repo. Its release workflow publishes + `-.tmxplug` and the `.sig`. +2. Replace the plugin's entry with `source: "tmxplug"`, the release `url` and + the `sha256` the workflow printed (or `sha256sum` of the download). +3. Delete `plugins//` and run `npm install` so the workspace goes away. +4. Run `npm run build:plugins` and check `dist/plugins//` holds the + plugin. + +A tmxplug plugin is not in the Vite dev server's plugin glob, so `npm run dev` +does not hot-reload it; its built frontend still loads from +`/plugin-assets//`. + ### 14. Upgrades must be lossless Every move of data, settings or host columns into a plugin comes with a @@ -2575,8 +2609,11 @@ None of the checks in this document stop that. They are not a sandbox. ### What the system does about it - **Signing and review.** Official plugins live in this repo and ship in the - Termix image; they are reviewed like core. Signed community plugins and - their review process arrive with the install UI in 3.0.0. + Termix image; they are reviewed like core and trusted by shipping inside + it. A plugin outside the image is a `.tmxplug` signed by a key compiled + into Termix (see [Packaging and signing](#packaging-and-signing)), and + `TERMIX_REQUIRE_SIGNED_PLUGINS=true` refuses anything else. Community + review and the install UI arrive in 3.0.0. - **The kill list (3.0.0).** A signed list of plugin versions known to be bad, which the server refuses to load, so a bad plugin can be switched off on every install without waiting for each admin. @@ -2612,7 +2649,8 @@ None of the checks in this document stop that. They are not a sandbox. ### Before installing a community plugin - **Know where it came from.** Install from a source you would trust with - root on the server, and check the signature once 3.0.0 has them. + root on the server. Prefer a signed `.tmxplug` and run with + `TERMIX_REQUIRE_SIGNED_PLUGINS=true`. - **Read the capabilities.** Anything critical or high (`credentials:read`, `system:tls`, `ssh:connect`, `process:spawn`, `users:write`, `auth:provide`, `device:serial`) means the plugin can reach your hosts, @@ -2642,10 +2680,13 @@ keeps working unchanged. | `termix-plugin validate` | Runs the SDK's `parseManifest`, checks the files the manifest names exist, and checks that migrations only touch this plugin's tables and exist for every dialect. | | `termix-plugin migrations` | Diffs `src/backend/tables.ts` against `migrations/snapshot.json` and writes one `.sql` per dialect. `--check` fails when a definition changed without a migration. A rename or a type change is refused rather than guessed at, because that is a data decision. | | `termix-plugin test` | Runs the plugin's vitest suite. | -| `termix-plugin pack` | Writes a `.tgz` of the manifest, `dist/`, locales, migrations and README. **D3** adds signing. | +| `termix-plugin pack` | Validates, then writes `-.tmxplug` (see [Packaging and signing](#packaging-and-signing)). `--out ` picks the folder. | +| `termix-plugin sign` | Signs a `.tmxplug` with the key in `TERMIX_PLUGIN_SIGNING_KEY` and writes `.tmxplug.sig`. | +| `termix-plugin verify` | Checks a `.tmxplug` against its `.sig` and `--key [,]`. | +| `termix-plugin keygen` | Writes a new Ed25519 private key to `termix-plugin-signing.key` and prints the public key and key id. Never prints the private key. | -`npm run build:plugins` builds every plugin and stages the result in -`dist/plugins//`, which is what `getBundledPluginsDir()` resolves to and +`npm run build:plugins` stages every plugin listed in +`docker/bundled-plugins.json` in `dist/plugins//`, which is what `getBundledPluginsDir()` resolves to and what Docker and electron-builder package. `npm run build`, `build:backend` and `dev:backend` all call it. @@ -2653,6 +2694,52 @@ TypeScript only type-checks (`tsconfig.plugins.backend.json` and `tsconfig.plugins.frontend.json`, `noEmit`); esbuild does every emit. Nothing is written next to a source file. +### Packaging and signing + +**The `.tmxplug` file.** A gzipped tar of `manifest.json`, `dist/`, +`locales/`, `migrations/`, `README.md`, `CHANGELOG.md` and `icon.svg` +(whichever exist). `pack` refuses when `validate` finds an error. The archive +is reproducible: entries sorted by path, files only, mode 0644, owner 0, +mtime 0, and a gzip header with no timestamp and a fixed OS byte. The same +tag rebuilt on any machine gives the same sha256, as long as the build output +is the same (check out with the same line endings). + +**Signatures.** `termix-plugin sign` makes an Ed25519 signature over the raw +32-byte sha256 of the `.tmxplug` and writes it, base64, to +`.tmxplug.sig`. The private key comes from `TERMIX_PLUGIN_SIGNING_KEY` +(base64 PKCS8 as `keygen` writes it, or PEM); in CI it is a GitHub Actions +secret and it is never committed. A registry index carries the same base64 +string in each version's `signature`. + +**Pinned trust.** `src/backend/plugins/trust.ts` holds the registry public +keys as compile-time constants (`TRUSTED_PLUGIN_KEYS`) and +`verifyPluginArtifact(buffer, sha256, signature)`, which checks the hash and +then the signature against each pinned key. A key read from a registry index, +a download or the data directory is never trusted: whoever serves the index +could sign anything with it. Rotation needs a Termix release: add the new +key, ship, sign with it, and keep the old key for one release cycle so late +upgraders still accept plugins signed before the switch. The steps are in the +Termix-Registry README. + +**What the server loads.** Bundled plugins (`dist/plugins/`) are trusted by +shipping in the image and are not checked. In the data directory, +`/plugins/` holds either a plugin folder (as before) or a +`.tmxplug` with an optional `.tmxplug.sig` next to it. A +`.tmxplug` is unpacked fresh on every load into +`/plugins/.unpacked//` (plain files only, no path outside it). +A `.sig` that does not verify always blocks the plugin. With +`TERMIX_REQUIRE_SIGNED_PLUGINS=true`, a plugin folder is blocked (it carries +no signature) and so is a `.tmxplug` without a valid `.sig`. A blocked +plugin is logged and skipped like any other plugin that fails to load. + +**Publishing the SDK.** Plugins outside this repo depend on +`@termix/plugin-sdk` from npm. It is published from this repo with +`npm run build:sdk && npm publish --workspace @termix/plugin-sdk`; the +package's `files` list ships `dist/` with its `.d.ts` files, the CLI, the +manifest schema and `tsconfig.plugin.json`. Outside a Termix checkout the +vitest preset does not alias the shell's modules, so `renderWithApp` and +`@termix/plugin-sdk/ui` are only available to plugins in this repo for now. + ### Host-provided packages These ship with the Termix server and are never bundled into a plugin, because diff --git a/packages/plugin-sdk/FINISH-LIST.md b/packages/plugin-sdk/FINISH-LIST.md index 99e36f0db..69f67845a 100644 --- a/packages/plugin-sdk/FINISH-LIST.md +++ b/packages/plugin-sdk/FINISH-LIST.md @@ -179,6 +179,29 @@ with a real install, a real device or a repo outside this one. Plugins > Permissions lists a plugin's public routes; a disabled plugin's `/plugin-api` URL answers 503; a share-link guest who leaves cannot type. +## After D3 (needs the repos and keys) + +- Run `termix-plugin keygen`, then pin the public key in + `TRUSTED_PLUGIN_KEYS` (`src/backend/plugins/trust.ts`) and save it as + `keys/official.pub` in Termix-Registry. Until then the list is empty and + `TERMIX_REQUIRE_SIGNED_PLUGINS=true` blocks every user plugin. Steps are in + the Termix-Registry README. +- Create the Termix-Registry and Termix-Plugin-Template repos from the local + folders next to this repo, and set `TERMIX_PLUGIN_SIGNING_KEY` and + `REGISTRY_TOKEN` secrets. +- Publish the SDK (`npm run build:sdk && npm publish --workspace +@termix/plugin-sdk`), then run `npm install` in Termix-Plugin-Template and + commit its `package-lock.json`; its release workflow uses `npm ci`. +- Plugins outside this repo cannot use `renderWithApp` or + `@termix/plugin-sdk/ui` (types and the test host come from core's source). + Ship them in the SDK package before community plugins need UI components. +- `npm run test:plugins` on Windows sometimes fails with "Worker exited + unexpectedly" in a random plugin (file-manager once, workspaces once); the + same suite passes alone and a rerun of the full run passed. Find out why + the vitest fork dies (D0). +- Check by hand: drop a signed `.tmxplug` into `/plugins/` on a + real install, with and without `TERMIX_REQUIRE_SIGNED_PLUGINS=true`. + ## Left for later - 3.0.0: drop the migrated 2.8 settings rows (`guac_url`, `step_ca_url`, diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md new file mode 100644 index 000000000..c98b02321 --- /dev/null +++ b/packages/plugin-sdk/README.md @@ -0,0 +1,19 @@ +# @termix/plugin-sdk + +Everything a [Termix](https://github.com/Termix-SSH/Termix) plugin builds +against: the types for `activate(ctx)` and `activate(app)`, table +definitions, the manifest schema, test helpers, a vitest preset and the +`termix-plugin` CLI. + +```bash +npm install --save-dev @termix/plugin-sdk +npx termix-plugin build # bundle dist/backend.js and dist/frontend.js +npx termix-plugin test # run the plugin's vitest suite +npx termix-plugin validate # check manifest.json and the files it names +npx termix-plugin pack # write -.tmxplug +npx termix-plugin sign # needs TERMIX_PLUGIN_SIGNING_KEY +``` + +Start from +[Termix-Plugin-Template](https://github.com/Termix-SSH/Termix-Plugin-Template). +The full contract is in [ARCHITECTURE.md](./ARCHITECTURE.md). diff --git a/packages/plugin-sdk/cli/commands/pack.mjs b/packages/plugin-sdk/cli/commands/pack.mjs index 8f6cc52ff..73115b12f 100644 --- a/packages/plugin-sdk/cli/commands/pack.mjs +++ b/packages/plugin-sdk/cli/commands/pack.mjs @@ -1,19 +1,23 @@ import fs from "node:fs"; import path from "node:path"; -import { create } from "tar"; import { readManifest } from "../lib/plugin-dir.mjs"; +import { collectFiles, createTmxplug } from "../lib/tmxplug.mjs"; +import { sha256 } from "../lib/signing.mjs"; +import { validate } from "./validate.mjs"; -/** Everything a server needs to run the plugin, and nothing else. D3 signs it. */ -const CONTENTS = [ - "manifest.json", - "dist", - "locales", - "migrations", - "README.md", - "CHANGELOG.md", -]; +function readOption(args, name) { + const index = args.indexOf(name); + if (index === -1) return null; + const value = args[index + 1]; + if (!value) throw new Error(`${name} needs a value`); + return value; +} -export async function pack({ cwd }) { +/** + * Writes -.tmxplug. Validates first and refuses on any error, + * so a broken manifest never becomes a release. + */ +export async function pack({ cwd, args = [] }) { const manifest = readManifest(cwd); const pluginId = manifest.id ?? path.basename(cwd); const version = manifest.version ?? "0.0.0"; @@ -22,12 +26,17 @@ export async function pack({ cwd }) { throw new Error(`${pluginId}: nothing built. Run termix-plugin build.`); } - const files = CONTENTS.filter((entry) => - fs.existsSync(path.join(cwd, entry)), - ); - const outfile = path.join(cwd, `${pluginId}-${version}.tgz`); + await validate({ cwd }); - await create({ gzip: true, cwd, file: outfile }, files); + const outDir = path.resolve(cwd, readOption(args, "--out") ?? "."); + fs.mkdirSync(outDir, { recursive: true }); + const outfile = path.join(outDir, `${pluginId}-${version}.tmxplug`); + + const archive = createTmxplug(cwd, collectFiles(cwd)); + fs.writeFileSync(outfile, archive); console.log(`packed ${path.basename(outfile)}`); + console.log(` size ${archive.length}`); + console.log(` sha256 ${sha256(archive).toString("hex")}`); + return outfile; } diff --git a/packages/plugin-sdk/cli/commands/sign.mjs b/packages/plugin-sdk/cli/commands/sign.mjs new file mode 100644 index 000000000..4d4fd0548 --- /dev/null +++ b/packages/plugin-sdk/cli/commands/sign.mjs @@ -0,0 +1,83 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + SIGNING_KEY_ENV, + generateKeyPair, + loadPrivateKey, + sha256, + signArtifact, + verifyArtifact, +} from "../lib/signing.mjs"; + +function readOption(args, name) { + const index = args.indexOf(name); + if (index === -1) return null; + const value = args[index + 1]; + if (!value) throw new Error(`${name} needs a value`); + return value; +} + +function artifactArg(cwd, args) { + const file = args.find((arg) => arg.endsWith(".tmxplug")); + if (!file) throw new Error("Pass the .tmxplug file to use"); + const full = path.resolve(cwd, file); + if (!fs.existsSync(full)) throw new Error(`${file} does not exist`); + return full; +} + +/** Writes .sig: base64 Ed25519 over the sha256 of the .tmxplug. */ +export async function sign({ cwd, args = [], env = process.env }) { + const file = artifactArg(cwd, args); + const key = loadPrivateKey(env[SIGNING_KEY_ENV]); + const buffer = fs.readFileSync(file); + const signature = signArtifact(buffer, key); + fs.writeFileSync(`${file}.sig`, `${signature}\n`); + console.log(`signed ${path.basename(file)}`); + console.log(` sha256 ${sha256(buffer).toString("hex")}`); + console.log(` signature ${signature}`); + return signature; +} + +/** Checks a .tmxplug against its .sig and one or more public keys. */ +export async function verify({ cwd, args = [] }) { + const file = artifactArg(cwd, args); + const keys = (readOption(args, "--key") ?? "") + .split(",") + .map((key) => key.trim()) + .filter(Boolean); + if (keys.length === 0) throw new Error("Pass --key "); + const sigFile = readOption(args, "--sig") ?? `${file}.sig`; + if (!fs.existsSync(sigFile)) throw new Error(`${sigFile} does not exist`); + + const matched = verifyArtifact( + fs.readFileSync(file), + fs.readFileSync(sigFile, "utf8"), + keys, + ); + if (!matched) + throw new Error(`${path.basename(file)}: signature is not valid`); + console.log(`ok ${path.basename(file)} signed by key ${matched}`); + return matched; +} + +/** + * Writes a new private key to a file and prints the public half. The + * private key never goes to stdout, so it does not end up in a CI log. + */ +export async function keygen({ cwd, args = [] }) { + const outDir = path.resolve(cwd, readOption(args, "--out") ?? "."); + const keyFile = path.join(outDir, "termix-plugin-signing.key"); + if (fs.existsSync(keyFile)) { + throw new Error(`${keyFile} already exists; move it first`); + } + const pair = generateKeyPair(); + fs.mkdirSync(outDir, { recursive: true }); + fs.writeFileSync(keyFile, `${pair.privateKey}\n`, { mode: 0o600 }); + console.log(`private key ${keyFile}`); + console.log(`public key ${pair.publicKey}`); + console.log(`key id ${pair.keyId}`); + console.log( + `Store the private key as ${SIGNING_KEY_ENV}, then delete the file.`, + ); + return { keyFile, publicKey: pair.publicKey, keyId: pair.keyId }; +} diff --git a/packages/plugin-sdk/cli/index.mjs b/packages/plugin-sdk/cli/index.mjs index 6b2bf1d18..5b6eb95df 100644 --- a/packages/plugin-sdk/cli/index.mjs +++ b/packages/plugin-sdk/cli/index.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * termix-plugin: build, validate, test and pack a Termix plugin. + * termix-plugin: build, validate, test, pack and sign a Termix plugin. * * Run from a plugin directory (npm run build inside plugins// does). */ @@ -10,9 +10,19 @@ import { build } from "./commands/build.mjs"; import { validate } from "./commands/validate.mjs"; import { test } from "./commands/test.mjs"; import { pack } from "./commands/pack.mjs"; +import { sign, verify, keygen } from "./commands/sign.mjs"; import { migrations } from "./commands/migrations.mjs"; -const COMMANDS = { build, validate, test, pack, migrations }; +const COMMANDS = { + build, + validate, + test, + pack, + sign, + verify, + keygen, + migrations, +}; const [command, ...args] = process.argv.slice(2); @@ -24,7 +34,10 @@ if (!command || command === "--help" || command === "-h") { " build Bundle the plugin into dist/", " validate Check manifest.json and the files it names", " test Run the plugin's vitest suite", - " pack Write a .tgz of the built plugin", + " pack Write -.tmxplug of the built plugin [--out dir]", + " sign Sign a .tmxplug with TERMIX_PLUGIN_SIGNING_KEY ", + " verify Check a .tmxplug's .sig --key [,...]", + " keygen Write a new signing key pair [--out dir]", " migrations Generate migrations from the plugin's table definitions", ].join("\n"), ); diff --git a/packages/plugin-sdk/cli/lib/signing.mjs b/packages/plugin-sdk/cli/lib/signing.mjs new file mode 100644 index 000000000..3ed942552 --- /dev/null +++ b/packages/plugin-sdk/cli/lib/signing.mjs @@ -0,0 +1,86 @@ +import crypto from "node:crypto"; + +/** The env var that holds the base64 PKCS8 private key. Never commit it. */ +export const SIGNING_KEY_ENV = "TERMIX_PLUGIN_SIGNING_KEY"; + +// DER prefix of an Ed25519 SubjectPublicKeyInfo; the raw 32-byte key follows. +const SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + +export function sha256(buffer) { + return crypto.createHash("sha256").update(buffer).digest(); +} + +export function publicKeyFromRaw(base64) { + const raw = Buffer.from(base64.trim(), "base64"); + if (raw.length !== 32) throw new Error("A public key is 32 bytes, base64"); + return crypto.createPublicKey({ + key: Buffer.concat([SPKI_PREFIX, raw]), + format: "der", + type: "spki", + }); +} + +export function rawPublicKey(keyObject) { + const der = keyObject.export({ format: "der", type: "spki" }); + return der.subarray(SPKI_PREFIX.length).toString("base64"); +} + +/** First 16 hex chars of the sha256 of the raw public key. */ +export function keyId(publicKeyBase64) { + return sha256(Buffer.from(publicKeyBase64, "base64")) + .toString("hex") + .slice(0, 16); +} + +export function generateKeyPair() { + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + const publicBase64 = rawPublicKey(publicKey); + return { + publicKey: publicBase64, + privateKey: privateKey + .export({ format: "der", type: "pkcs8" }) + .toString("base64"), + keyId: keyId(publicBase64), + }; +} + +/** Accepts base64 PKCS8 DER (what keygen writes) or a PEM block. */ +export function loadPrivateKey(value) { + if (!value || !value.trim()) { + throw new Error(`${SIGNING_KEY_ENV} is not set`); + } + const trimmed = value.trim(); + const key = trimmed.startsWith("-----BEGIN") + ? crypto.createPrivateKey(trimmed) + : crypto.createPrivateKey({ + key: Buffer.from(trimmed, "base64"), + format: "der", + type: "pkcs8", + }); + if (key.asymmetricKeyType !== "ed25519") { + throw new Error("The signing key must be an Ed25519 key"); + } + return key; +} + +/** Ed25519 over the raw 32-byte sha256 of the artifact, base64. */ +export function signArtifact(buffer, privateKey) { + return crypto.sign(null, sha256(buffer), privateKey).toString("base64"); +} + +/** The id of the first key that verifies the signature, or null. */ +export function verifyArtifact(buffer, signatureBase64, publicKeys) { + const digest = sha256(buffer); + const signature = Buffer.from(signatureBase64.trim(), "base64"); + if (signature.length !== 64) return null; + for (const key of publicKeys) { + try { + if (crypto.verify(null, digest, publicKeyFromRaw(key), signature)) { + return keyId(key); + } + } catch { + // A malformed key is just a key that does not match. + } + } + return null; +} diff --git a/packages/plugin-sdk/cli/lib/tmxplug.mjs b/packages/plugin-sdk/cli/lib/tmxplug.mjs new file mode 100644 index 000000000..d44d44fc6 --- /dev/null +++ b/packages/plugin-sdk/cli/lib/tmxplug.mjs @@ -0,0 +1,106 @@ +import fs from "node:fs"; +import path from "node:path"; +import zlib from "node:zlib"; + +/** What a .tmxplug holds, in the order the spec lists it. */ +export const TMXPLUG_CONTENTS = [ + "manifest.json", + "dist", + "locales", + "migrations", + "README.md", + "CHANGELOG.md", + "icon.svg", +]; + +const BLOCK = 512; + +/** + * Every file under the given top-level entries, as sorted posix paths. + * Directories are implied by their files, so none get their own entry. + */ +export function collectFiles(cwd, entries = TMXPLUG_CONTENTS) { + const files = []; + const walk = (rel) => { + const full = path.join(cwd, rel); + const stat = fs.lstatSync(full); + if (stat.isSymbolicLink()) { + throw new Error(`${rel} is a symlink; a .tmxplug holds plain files only`); + } + if (stat.isDirectory()) { + for (const child of fs.readdirSync(full)) { + walk(path.posix.join(rel, child)); + } + } else if (stat.isFile()) { + files.push(rel); + } + }; + for (const entry of entries) { + if (fs.existsSync(path.join(cwd, entry))) walk(entry); + } + return files.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); +} + +function writeString(buf, offset, length, value) { + const bytes = Buffer.from(value, "utf8"); + if (bytes.length > length) throw new Error(`"${value}" is too long`); + bytes.copy(buf, offset); +} + +function writeOctal(buf, offset, length, value) { + writeString(buf, offset, length, value.toString(8).padStart(length - 1, "0")); +} + +function splitName(name) { + if (Buffer.byteLength(name) <= 100) return { name, prefix: "" }; + for (let i = name.length - 1; i > 0; i--) { + if (name[i] !== "/") continue; + const prefix = name.slice(0, i); + const rest = name.slice(i + 1); + if (Buffer.byteLength(prefix) <= 155 && Buffer.byteLength(rest) <= 100) { + return { name: rest, prefix }; + } + } + throw new Error(`${name} is too long for a .tmxplug entry`); +} + +function header(name, size) { + const buf = Buffer.alloc(BLOCK); + const split = splitName(name); + writeString(buf, 0, 100, split.name); + writeOctal(buf, 100, 8, 0o644); + writeOctal(buf, 108, 8, 0); + writeOctal(buf, 116, 8, 0); + writeOctal(buf, 124, 12, size); + writeOctal(buf, 136, 12, 0); + buf.fill(0x20, 148, 156); + buf[156] = 0x30; + writeString(buf, 257, 6, "ustar"); + writeString(buf, 263, 2, "00"); + writeString(buf, 345, 155, split.prefix); + let sum = 0; + for (const byte of buf) sum += byte; + writeString(buf, 148, 8, sum.toString(8).padStart(6, "0") + "\0 "); + return buf; +} + +/** + * A gzipped ustar archive that depends only on file names and contents: + * fixed mode, owner and mtime, sorted entries, and a gzip header with no + * timestamp and a fixed OS byte (Node on Windows writes a different one). + * Rebuilding from the same tag gives the same sha256 on any machine. + */ +export function createTmxplug(cwd, files) { + const parts = []; + for (const rel of files) { + const data = fs.readFileSync(path.join(cwd, rel)); + parts.push(header(rel, data.length), data); + const pad = (BLOCK - (data.length % BLOCK)) % BLOCK; + if (pad) parts.push(Buffer.alloc(pad)); + } + parts.push(Buffer.alloc(BLOCK * 2)); + const gz = zlib.gzipSync(Buffer.concat(parts), { level: 9 }); + gz.writeUInt32LE(0, 4); + gz[9] = 0x03; + return gz; +} diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index b2fc07256..2082699e0 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -3,7 +3,16 @@ "version": "1.0.0", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/Termix-SSH/Termix.git", + "directory": "packages/plugin-sdk" + }, + "homepage": "https://github.com/Termix-SSH/Termix/blob/main/packages/plugin-sdk/ARCHITECTURE.md", "type": "module", + "engines": { + "node": ">=22" + }, "bin": { "termix-plugin": "./cli/index.mjs" }, @@ -68,10 +77,12 @@ "cli", "schema", "tsconfig.plugin.json", - "ARCHITECTURE.md" + "ARCHITECTURE.md", + "README.md" ], "scripts": { - "build": "tsc -p tsconfig.json" + "build": "tsc -p tsconfig.json", + "prepublishOnly": "npm run build" }, "dependencies": { "esbuild": "^0.28.1", @@ -80,7 +91,10 @@ }, "peerDependencies": { "drizzle-orm": "*", - "ssh2": "*" + "ssh2": "*", + "better-sqlite3": "*", + "react": "*", + "vitest": "*" }, "peerDependenciesMeta": { "drizzle-orm": { @@ -88,6 +102,18 @@ }, "ssh2": { "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "react": { + "optional": true + }, + "vitest": { + "optional": true } + }, + "publishConfig": { + "access": "public" } } diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index 2142830df..be485f5ab 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -1846,7 +1846,13 @@ export async function createTestDb( ); const handle = drizzle(sqlite); - const pluginId = path.basename(pluginDir); + // The manifest id, not the folder name: a plugin's own repo is rarely + // named after its id. + const manifestPath = path.join(pluginDir, "manifest.json"); + const pluginId: string = + (fs.existsSync(manifestPath) && + JSON.parse(fs.readFileSync(manifestPath, "utf8")).id) || + path.basename(pluginDir); const applied: string[] = []; let persisted = 0; diff --git a/packages/plugin-sdk/src/vitest-preset.ts b/packages/plugin-sdk/src/vitest-preset.ts index cc25592d9..c93c01dff 100644 --- a/packages/plugin-sdk/src/vitest-preset.ts +++ b/packages/plugin-sdk/src/vitest-preset.ts @@ -42,7 +42,11 @@ export function pluginVitestConfig( options: PluginVitestOptions = {}, ) { const pluginDir = path.dirname(fileURLToPath(configUrl)); - const pluginId = path.basename(pluginDir); + const manifestPath = path.join(pluginDir, "manifest.json"); + const pluginId: string = + (fs.existsSync(manifestPath) && + JSON.parse(fs.readFileSync(manifestPath, "utf8")).id) || + path.basename(pluginDir); const setupFile = fileURLToPath( new URL("./testing/setup.js", import.meta.url), ); diff --git a/scripts/build-plugins.cjs b/scripts/build-plugins.cjs index 55c19bb62..32a18111e 100644 --- a/scripts/build-plugins.cjs +++ b/scripts/build-plugins.cjs @@ -1,39 +1,39 @@ /** - * Builds every plugin workspace, then stages the result in dist/plugins. + * Stages the plugins listed in docker/bundled-plugins.json in dist/plugins. * * getBundledPluginsDir() in src/backend/plugins/paths.ts resolves * dist/backend/backend/plugins -> dist/plugins, which is where this writes. - * Only what a server needs at runtime is copied: the manifest, the built - * bundles, locales and migrations. Source and tests stay out of the image. + * A workspace plugin is built from plugins/; a tmxplug plugin is + * downloaded (or read from a path), checked against its pinned sha256 and + * unpacked. Either way only what a server needs at runtime lands there. */ const fs = require("node:fs"); const path = require("node:path"); const { execFileSync } = require("node:child_process"); +const { + loadBundledPlugins, + fetchArtifact, + extractArtifact, +} = require("./lib/bundled-plugins.cjs"); const root = path.resolve(__dirname, ".."); const source = path.join(root, "plugins"); const destination = path.join(root, "dist", "plugins"); const cli = path.join(root, "packages", "plugin-sdk", "cli", "index.mjs"); -const SHIPPED = ["manifest.json", "dist", "locales", "migrations", "README.md"]; +// Same list as a .tmxplug, see packages/plugin-sdk/cli/lib/tmxplug.mjs. +const SHIPPED = [ + "manifest.json", + "dist", + "locales", + "migrations", + "README.md", + "CHANGELOG.md", + "icon.svg", +]; -if (!fs.existsSync(source)) { - console.log("No plugins/ directory, nothing to bundle."); - process.exit(0); -} - -const pluginIds = fs - .readdirSync(source, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .filter((entry) => - fs.existsSync(path.join(source, entry.name, "manifest.json")), - ) - .map((entry) => entry.name); - -fs.rmSync(destination, { recursive: true, force: true }); - -for (const id of pluginIds) { +function stageWorkspace(id) { const pluginDir = path.join(source, id); execFileSync(process.execPath, [cli, "build"], { @@ -41,8 +41,7 @@ for (const id of pluginIds) { stdio: "inherit", }); - const built = path.join(pluginDir, "dist", "backend.js"); - if (!fs.existsSync(built)) { + if (!fs.existsSync(path.join(pluginDir, "dist", "backend.js"))) { throw new Error(`${id} produced no dist/backend.js`); } @@ -55,4 +54,31 @@ for (const id of pluginIds) { } } -console.log(`Bundled ${pluginIds.length} plugin(s): ${pluginIds.join(", ")}`); +async function main() { + const plugins = loadBundledPlugins(root); + + fs.rmSync(destination, { recursive: true, force: true }); + + for (const plugin of plugins) { + if (plugin.source === "workspace") { + stageWorkspace(plugin.id); + } else { + const buffer = await fetchArtifact(plugin, root); + await extractArtifact( + buffer, + path.join(destination, plugin.id), + plugin.id, + ); + console.log(`unpacked ${plugin.id}`); + } + } + + console.log( + `Bundled ${plugins.length} plugin(s): ${plugins.map((p) => p.id).join(", ")}`, + ); +} + +main().catch((error) => { + console.error(error?.message ?? error); + process.exit(1); +}); diff --git a/scripts/bundled-plugins.test.ts b/scripts/bundled-plugins.test.ts new file mode 100644 index 000000000..24234ef44 --- /dev/null +++ b/scripts/bundled-plugins.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it } 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 { + parseBundledPlugins, + loadBundledPlugins, + fetchArtifact, + extractArtifact, +} from "./lib/bundled-plugins.cjs"; + +const cleanups: Array<() => void> = []; + +function tempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-bundled-")); + cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +} + +afterEach(() => { + while (cleanups.length) cleanups.pop()?.(); +}); + +const SHA = "a".repeat(64); + +async function artifactFor(id: string): Promise { + const src = tempDir(); + fs.writeFileSync(path.join(src, "manifest.json"), JSON.stringify({ id })); + fs.mkdirSync(path.join(src, "dist")); + fs.writeFileSync(path.join(src, "dist", "backend.js"), "export {}\n"); + const file = path.join(tempDir(), `${id}.tmxplug`); + await tar.c({ gzip: true, file, cwd: src }, ["manifest.json", "dist"]); + return fs.readFileSync(file); +} + +const sha256 = (buffer: Buffer) => + crypto.createHash("sha256").update(buffer).digest("hex"); + +describe("parseBundledPlugins", () => { + it("accepts workspace and tmxplug entries", () => { + const { plugins, problems } = parseBundledPlugins( + { + plugins: [ + { id: "one", source: "workspace" }, + { + id: "two", + source: "tmxplug", + url: "https://x/two.tmxplug", + sha256: SHA, + }, + { + id: "three", + source: "tmxplug", + path: "vendor/three.tmxplug", + sha256: SHA, + }, + ], + }, + ["one"], + ); + expect(problems).toEqual([]); + expect(plugins.map((p: { id: string }) => p.id)).toEqual([ + "one", + "two", + "three", + ]); + }); + + it("reports a plugins folder nobody listed", () => { + const { problems } = parseBundledPlugins( + { plugins: [{ id: "one", source: "workspace" }] }, + ["one", "stray"], + ); + expect(problems).toEqual([ + "plugins/stray is not listed in docker/bundled-plugins.json", + ]); + }); + + it("reports bad entries", () => { + const { problems } = parseBundledPlugins( + { + plugins: [ + { id: "gone", source: "workspace" }, + { id: "nohash", source: "tmxplug", url: "https://x" }, + { id: "plain", source: "tmxplug", url: "http://x", sha256: SHA }, + { id: "still-here", source: "tmxplug", path: "a", sha256: SHA }, + { id: "gone", source: "workspace" }, + { id: "odd", source: "npm" }, + ], + }, + ["still-here"], + ); + expect(problems).toEqual([ + "gone is a workspace plugin but plugins/gone does not exist", + "nohash needs a lowercase hex sha256", + "plain needs exactly one of an https url or a path", + "still-here comes from a .tmxplug, so delete plugins/still-here", + "gone is listed twice", + 'odd has an unknown source "npm"', + ]); + }); + + it("matches the plugins folder in this repo", () => { + const root = path.resolve(__dirname, ".."); + expect(() => loadBundledPlugins(root)).not.toThrow(); + }); +}); + +describe("tmxplug entries", () => { + it("unpacks an artifact that matches its pinned sha256", async () => { + const root = tempDir(); + const buffer = await artifactFor("demo"); + fs.writeFileSync(path.join(root, "demo.tmxplug"), buffer); + + const fetched = await fetchArtifact( + { id: "demo", path: "demo.tmxplug", sha256: sha256(buffer) }, + root, + ); + const out = path.join(root, "dist", "plugins", "demo"); + await extractArtifact(fetched, out, "demo"); + expect(fs.existsSync(path.join(out, "dist", "backend.js"))).toBe(true); + }); + + it("refuses an artifact whose sha256 differs", async () => { + const root = tempDir(); + fs.writeFileSync( + path.join(root, "demo.tmxplug"), + await artifactFor("demo"), + ); + await expect( + fetchArtifact({ id: "demo", path: "demo.tmxplug", sha256: SHA }, root), + ).rejects.toThrow(/bundled-plugins.json pins/); + }); + + it("downloads once and reuses the cache", async () => { + const root = tempDir(); + const buffer = await artifactFor("demo"); + let calls = 0; + const fakeFetch = async () => { + calls++; + return new Response(buffer); + }; + const entry = { + id: "demo", + url: "https://example.test/demo.tmxplug", + sha256: sha256(buffer), + }; + await fetchArtifact(entry, root, fakeFetch); + await fetchArtifact(entry, root, fakeFetch); + expect(calls).toBe(1); + }); + + it("refuses an artifact for a different id", async () => { + const out = path.join(tempDir(), "demo"); + await expect( + extractArtifact(await artifactFor("other"), out, "demo"), + ).rejects.toThrow(/is for "other"/); + }); +}); diff --git a/scripts/lib/bundled-plugins.cjs b/scripts/lib/bundled-plugins.cjs new file mode 100644 index 000000000..c4681d457 --- /dev/null +++ b/scripts/lib/bundled-plugins.cjs @@ -0,0 +1,196 @@ +/** + * Reads docker/bundled-plugins.json: which plugins ship in the image and + * where each comes from. + * + * { "id": "x", "source": "workspace" } built from plugins/x + * { "id": "x", "source": "tmxplug", "url": "...", "sha256": "..." } + * { "id": "x", "source": "tmxplug", "path": "...", "sha256": "..." } + * + * A tmxplug entry is pinned by sha256 in this file, which is reviewed like + * any other change, so the build does not need the registry signature. + */ + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); + +const ID = /^[a-z0-9][a-z0-9-]*$/; +const SHA256 = /^[0-9a-f]{64}$/; + +function parseBundledPlugins(raw, workspaceIds) { + const problems = []; + const list = raw && Array.isArray(raw.plugins) ? raw.plugins : null; + if (!list) { + return { + plugins: [], + problems: ['bundled-plugins.json needs a "plugins" array'], + }; + } + + const seen = new Set(); + const plugins = []; + for (const [index, entry] of list.entries()) { + const where = `plugins[${index}]`; + if (!entry || typeof entry.id !== "string" || !ID.test(entry.id)) { + problems.push(`${where} needs a valid id`); + continue; + } + if (seen.has(entry.id)) { + problems.push(`${entry.id} is listed twice`); + continue; + } + seen.add(entry.id); + + if (entry.source === "workspace") { + if (!workspaceIds.includes(entry.id)) { + problems.push( + `${entry.id} is a workspace plugin but plugins/${entry.id} does not exist`, + ); + continue; + } + plugins.push({ id: entry.id, source: "workspace" }); + } else if (entry.source === "tmxplug") { + const hasUrl = + typeof entry.url === "string" && entry.url.startsWith("https://"); + const hasPath = typeof entry.path === "string" && entry.path.length > 0; + if (hasUrl === hasPath) { + problems.push( + `${entry.id} needs exactly one of an https url or a path`, + ); + continue; + } + if (typeof entry.sha256 !== "string" || !SHA256.test(entry.sha256)) { + problems.push(`${entry.id} needs a lowercase hex sha256`); + continue; + } + if (workspaceIds.includes(entry.id)) { + problems.push( + `${entry.id} comes from a .tmxplug, so delete plugins/${entry.id}`, + ); + continue; + } + plugins.push({ + id: entry.id, + source: "tmxplug", + url: hasUrl ? entry.url : null, + path: hasPath ? entry.path : null, + sha256: entry.sha256, + }); + } else { + problems.push(`${entry.id} has an unknown source "${entry.source}"`); + } + } + + for (const id of workspaceIds) { + if (!seen.has(id)) { + problems.push( + `plugins/${id} is not listed in docker/bundled-plugins.json`, + ); + } + } + + return { plugins, problems }; +} + +function loadBundledPlugins(root) { + const configPath = path.join(root, "docker", "bundled-plugins.json"); + const pluginsDir = path.join(root, "plugins"); + const workspaceIds = fs.existsSync(pluginsDir) + ? fs + .readdirSync(pluginsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .filter((entry) => + fs.existsSync(path.join(pluginsDir, entry.name, "manifest.json")), + ) + .map((entry) => entry.name) + .sort() + : []; + + const raw = JSON.parse(fs.readFileSync(configPath, "utf8")); + const { plugins, problems } = parseBundledPlugins(raw, workspaceIds); + if (problems.length > 0) { + throw new Error(`docker/bundled-plugins.json:\n ${problems.join("\n ")}`); + } + return plugins; +} + +/** Refuses an artifact whose bytes are not the ones the config pinned. */ +function checkSha256(buffer, expected, id) { + const actual = crypto.createHash("sha256").update(buffer).digest("hex"); + if (actual !== expected) { + throw new Error( + `${id}: sha256 is ${actual}, bundled-plugins.json pins ${expected}`, + ); + } +} + +/** + * The .tmxplug bytes for an entry: a local path, or a download cached by + * sha256 so rebuilds do not fetch it again. + */ +async function fetchArtifact(entry, root, fetchImpl = fetch) { + if (entry.path) { + const buffer = fs.readFileSync(path.resolve(root, entry.path)); + checkSha256(buffer, entry.sha256, entry.id); + return buffer; + } + + const cacheDir = path.join( + root, + "node_modules", + ".cache", + "termix-bundled-plugins", + ); + const cached = path.join(cacheDir, `${entry.sha256}.tmxplug`); + if (fs.existsSync(cached)) { + const buffer = fs.readFileSync(cached); + checkSha256(buffer, entry.sha256, entry.id); + return buffer; + } + + const response = await fetchImpl(entry.url); + if (!response.ok) { + throw new Error(`${entry.id}: download failed with ${response.status}`); + } + const buffer = Buffer.from(await response.arrayBuffer()); + checkSha256(buffer, entry.sha256, entry.id); + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync(cached, buffer); + return buffer; +} + +/** Unpacks a checked .tmxplug into outDir and confirms its manifest id. */ +async function extractArtifact(buffer, outDir, id) { + const tar = require("tar"); + const { Readable } = require("node:stream"); + const { pipeline } = require("node:stream/promises"); + + fs.rmSync(outDir, { recursive: true, force: true }); + fs.mkdirSync(outDir, { recursive: true }); + await pipeline( + Readable.from(buffer), + tar.x({ + cwd: outDir, + strict: true, + filter: (_p, entry) => + entry.type === "File" || entry.type === "Directory", + }), + ); + + const manifestPath = path.join(outDir, "manifest.json"); + if (!fs.existsSync(manifestPath)) { + throw new Error(`${id}: the .tmxplug has no manifest.json`); + } + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + if (manifest.id !== id) { + throw new Error(`${id}: the .tmxplug is for "${manifest.id}"`); + } +} + +module.exports = { + parseBundledPlugins, + loadBundledPlugins, + checkSha256, + fetchArtifact, + extractArtifact, +}; diff --git a/scripts/plugin-pack.test.ts b/scripts/plugin-pack.test.ts new file mode 100644 index 000000000..b782aaef2 --- /dev/null +++ b/scripts/plugin-pack.test.ts @@ -0,0 +1,140 @@ +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 { pack } from "../packages/plugin-sdk/cli/commands/pack.mjs"; + +const cleanups: Array<() => void> = []; + +function tempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +} + +function writeFile(root: string, rel: string, content: string) { + const full = path.join(root, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); +} + +function fixturePlugin(manifest: Record = {}): string { + const dir = path.join(tempDir("termix-pack-"), "pack-fixture"); + writeFile( + dir, + "manifest.json", + JSON.stringify({ + id: "pack-fixture", + name: "Pack Fixture", + version: "1.2.3", + description: "Fixture for the pack tests.", + author: { name: "Termix Tests" }, + license: "MIT", + category: "Productivity", + engine: { termix: ">=2.9.0", api: "1" }, + capabilities: [], + ...manifest, + }), + ); + writeFile(dir, "dist/backend.js", "export async function activate() {}\n"); + writeFile(dir, "dist/frontend.js", "export function activate() {}\n"); + writeFile(dir, "locales/en.json", "{}\n"); + writeFile(dir, "migrations/sqlite/0001_init.sql", "SELECT 1;\n"); + writeFile(dir, "migrations/postgres/0001_init.sql", "SELECT 1;\n"); + writeFile(dir, "migrations/mysql/0001_init.sql", "SELECT 1;\n"); + writeFile(dir, "README.md", "# Fixture\n"); + writeFile(dir, "icon.svg", "\n"); + writeFile(dir, "src/backend/index.ts", "left out of the archive\n"); + return dir; +} + +function sha(file: string): string { + return crypto + .createHash("sha256") + .update(fs.readFileSync(file)) + .digest("hex"); +} + +async function entries(file: string): Promise { + const names: string[] = []; + await tar.t({ file, onReadEntry: (entry) => names.push(entry.path) }); + return names; +} + +afterEach(() => { + vi.restoreAllMocks(); + while (cleanups.length) cleanups.pop()?.(); +}); + +describe("termix-plugin pack", () => { + it("gives the same sha256 when rebuilt with new mtimes", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const dir = fixturePlugin(); + const first = await pack({ cwd: dir, args: ["--out", tempDir("a-")] }); + + const later = new Date(Date.now() + 86_400_000); + for (const rel of ["manifest.json", "dist/backend.js", "README.md"]) { + fs.utimesSync(path.join(dir, rel), later, later); + } + const second = await pack({ cwd: dir, args: ["--out", tempDir("b-")] }); + + expect(path.basename(first)).toBe("pack-fixture-1.2.3.tmxplug"); + expect(sha(first)).toBe(sha(second)); + }); + + it("holds only the shipped files, sorted, with fixed metadata", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const dir = fixturePlugin(); + const file = await pack({ cwd: dir, args: ["--out", tempDir("c-")] }); + + const names = await entries(file); + expect(names).toEqual([ + "README.md", + "dist/backend.js", + "dist/frontend.js", + "icon.svg", + "locales/en.json", + "manifest.json", + "migrations/mysql/0001_init.sql", + "migrations/postgres/0001_init.sql", + "migrations/sqlite/0001_init.sql", + ]); + + const meta: Array<{ mode: number; mtime: number; uid: number }> = []; + await tar.t({ + file, + onReadEntry: (entry) => + meta.push({ + mode: entry.mode ?? -1, + mtime: entry.mtime?.getTime() ?? -1, + uid: entry.uid ?? -1, + }), + }); + expect(new Set(meta.map((m) => JSON.stringify(m)))).toEqual( + new Set([JSON.stringify({ mode: 0o644, mtime: 0, uid: 0 })]), + ); + + const gz = fs.readFileSync(file); + expect(gz.readUInt32LE(4)).toBe(0); + expect(gz[9]).toBe(0x03); + + const out = tempDir("x-"); + await tar.x({ file, cwd: out }); + expect(fs.readFileSync(path.join(out, "dist/backend.js"), "utf8")).toBe( + "export async function activate() {}\n", + ); + }); + + it("refuses a manifest with errors", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + const dir = fixturePlugin({ capabilities: ["not:a-capability"] }); + const out = tempDir("d-"); + await expect(pack({ cwd: dir, args: ["--out", out] })).rejects.toThrow( + /manifest is not valid/, + ); + expect(fs.readdirSync(out)).toEqual([]); + }); +}); diff --git a/scripts/plugin-sign.test.ts b/scripts/plugin-sign.test.ts new file mode 100644 index 000000000..a11c1591a --- /dev/null +++ b/scripts/plugin-sign.test.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + keygen, + sign, + verify, +} from "../packages/plugin-sdk/cli/commands/sign.mjs"; +import { + generateKeyPair, + keyId, + loadPrivateKey, + signArtifact, + verifyArtifact, +} from "../packages/plugin-sdk/cli/lib/signing.mjs"; + +const cleanups: Array<() => void> = []; + +function tempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-sign-")); + cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +} + +afterEach(() => { + vi.restoreAllMocks(); + while (cleanups.length) cleanups.pop()?.(); +}); + +describe("signing helpers", () => { + const artifact = Buffer.from("plugin bytes"); + + it("verifies a signature from the matching key", () => { + const pair = generateKeyPair(); + const signature = signArtifact(artifact, loadPrivateKey(pair.privateKey)); + expect(verifyArtifact(artifact, signature, [pair.publicKey])).toBe( + pair.keyId, + ); + expect(keyId(pair.publicKey)).toMatch(/^[0-9a-f]{16}$/); + }); + + it("rejects a tampered artifact", () => { + const pair = generateKeyPair(); + const signature = signArtifact(artifact, loadPrivateKey(pair.privateKey)); + expect( + verifyArtifact(Buffer.from("plugin bytez"), signature, [pair.publicKey]), + ).toBeNull(); + }); + + it("rejects the wrong key", () => { + const signer = generateKeyPair(); + const other = generateKeyPair(); + const signature = signArtifact(artifact, loadPrivateKey(signer.privateKey)); + expect(verifyArtifact(artifact, signature, [other.publicKey])).toBeNull(); + expect( + verifyArtifact(artifact, signature, [other.publicKey, signer.publicKey]), + ).toBe(signer.keyId); + }); + + it("refuses a missing or non-Ed25519 key", () => { + expect(() => loadPrivateKey(undefined)).toThrow(/not set/); + expect(() => loadPrivateKey("not a key")).toThrow(); + }); +}); + +describe("keygen, sign and verify commands", () => { + it("round trips and never prints the private key", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const dir = tempDir(); + const file = path.join(dir, "demo-1.0.0.tmxplug"); + fs.writeFileSync(file, "archive"); + + const { keyFile, publicKey } = await keygen({ + cwd: dir, + args: ["--out", dir], + }); + const privateKey = fs.readFileSync(keyFile, "utf8").trim(); + expect(log.mock.calls.flat().join("\n")).not.toContain(privateKey); + + await sign({ + cwd: dir, + args: [file], + env: { TERMIX_PLUGIN_SIGNING_KEY: privateKey }, + }); + expect(fs.existsSync(`${file}.sig`)).toBe(true); + await expect( + verify({ cwd: dir, args: [file, "--key", publicKey] }), + ).resolves.toMatch(/^[0-9a-f]{16}$/); + + fs.writeFileSync(file, "archivE"); + await expect( + verify({ cwd: dir, args: [file, "--key", publicKey] }), + ).rejects.toThrow(/not valid/); + }); + + it("refuses to sign without the key env var", async () => { + const dir = tempDir(); + const file = path.join(dir, "demo-1.0.0.tmxplug"); + fs.writeFileSync(file, "archive"); + await expect(sign({ cwd: dir, args: [file], env: {} })).rejects.toThrow( + /TERMIX_PLUGIN_SIGNING_KEY is not set/, + ); + }); + + it("will not overwrite an existing key file", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const dir = tempDir(); + await keygen({ cwd: dir, args: ["--out", dir] }); + await expect(keygen({ cwd: dir, args: ["--out", dir] })).rejects.toThrow( + /already exists/, + ); + }); +}); diff --git a/src/backend/plugins/loader.ts b/src/backend/plugins/loader.ts index 55ac8e6d9..6b5dfdfac 100644 --- a/src/backend/plugins/loader.ts +++ b/src/backend/plugins/loader.ts @@ -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 .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 = new Set(), + ): Promise { + 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. * diff --git a/src/backend/plugins/paths.ts b/src/backend/plugins/paths.ts index 3fe379c56..dfa25c099 100644 --- a/src/backend/plugins/paths.ts +++ b/src/backend/plugins/paths.ts @@ -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. * diff --git a/src/backend/plugins/trust.ts b/src/backend/plugins/trust.ts new file mode 100644 index 000000000..8a97e4f1e --- /dev/null +++ b/src/backend/plugins/trust.ts @@ -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"; +} diff --git a/src/backend/tests/plugins/signed-plugins-gate.test.ts b/src/backend/tests/plugins/signed-plugins-gate.test.ts new file mode 100644 index 000000000..f22f52f0a --- /dev/null +++ b/src/backend/tests/plugins/signed-plugins-gate.test.ts @@ -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(); + 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 -1.0.0.tmxplug into `dir`, optionally with a .sig. */ +async function writeArtifact( + dir: string, + id: string, + sig: "valid" | "wrong-key" | "none", +): Promise { + 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 { + 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"); + }); +}); diff --git a/src/backend/tests/plugins/trust.test.ts b/src/backend/tests/plugins/trust.test.ts new file mode 100644 index 000000000..5e7156e47 --- /dev/null +++ b/src/backend/tests/plugins/trust.test.ts @@ -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; + }); +});