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

+3
View File
@@ -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/*
+132
View File
@@ -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"
}
]
}
+17 -1
View File
@@ -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
}
}
},
+1
View File
@@ -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"
},
+94 -7
View File
@@ -1639,10 +1639,44 @@ plugins/<id>/
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/<id>/`:
```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/<id>/`. 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
`<id>-<version>.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/<id>/` and run `npm install` so the workspace goes away.
4. Run `npm run build:plugins` and check `dist/plugins/<id>/` 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/<id>/`.
### 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 `<id>-<version>.tmxplug` (see [Packaging and signing](#packaging-and-signing)). `--out <dir>` picks the folder. |
| `termix-plugin sign` | Signs a `.tmxplug` with the key in `TERMIX_PLUGIN_SIGNING_KEY` and writes `<file>.tmxplug.sig`. |
| `termix-plugin verify` | Checks a `.tmxplug` against its `.sig` and `--key <base64>[,<base64>]`. |
| `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/<id>/`, which is what `getBundledPluginsDir()` resolves to and
`npm run build:plugins` stages every plugin listed in
`docker/bundled-plugins.json` in `dist/plugins/<id>/`, 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
`<file>.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,
`<DATA_DIR>/plugins/` holds either a plugin folder (as before) or a
`<name>.tmxplug` with an optional `<name>.tmxplug.sig` next to it. A
`.tmxplug` is unpacked fresh on every load into
`<DATA_DIR>/plugins/.unpacked/<id>/` (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
+23
View File
@@ -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 `<DATA_DIR>/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`,
+19
View File
@@ -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 <id>-<version>.tmxplug
npx termix-plugin sign <file.tmxplug> # 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).
+25 -16
View File
@@ -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 <id>-<version>.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;
}
+83
View File
@@ -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 <file>.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 <base64 public 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 };
}
+16 -3
View File
@@ -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/<id>/ 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 <id>-<version>.tmxplug of the built plugin [--out dir]",
" sign Sign a .tmxplug with TERMIX_PLUGIN_SIGNING_KEY <file>",
" verify Check a .tmxplug's .sig <file> --key <base64>[,...]",
" keygen Write a new signing key pair [--out dir]",
" migrations Generate migrations from the plugin's table definitions",
].join("\n"),
);
+86
View File
@@ -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;
}
+106
View File
@@ -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;
}
+29 -3
View File
@@ -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"
}
}
+7 -1
View File
@@ -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;
+5 -1
View File
@@ -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),
);
+49 -23
View File
@@ -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/<id>; 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);
});
+161
View File
@@ -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<Buffer> {
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"/);
});
});
+196
View File
@@ -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,
};
+140
View File
@@ -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, unknown> = {}): 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", "<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<string[]> {
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([]);
});
});
+114
View File
@@ -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/,
);
});
});
+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";
}
@@ -0,0 +1,158 @@
/**
* TERMIX_REQUIRE_SIGNED_PLUGINS against the real loader and real archives.
* The pinned key list is swapped for a test key; nothing else is mocked.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import * as tar from "tar";
import { createFixturePlugin } from "./fixture-plugin.js";
import { PluginLoader } from "../../plugins/loader.js";
const testKey = vi.hoisted(() => ({
privateKey: null as crypto.KeyObject | null,
key: { id: "test", publicKey: "", addedIn: "2.9.0" },
}));
vi.mock("../../plugins/trust.js", async (importActual) => {
const actual = await importActual<typeof import("../../plugins/trust.js")>();
return {
...actual,
verifyPluginArtifact: (buffer: Buffer, sha256: string, signature: string) =>
actual.verifyPluginArtifact(buffer, sha256, signature, [testKey.key]),
};
});
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginStorageRepository: () => ({
get: async () => null,
set: async () => {},
delete: async () => false,
listKeys: async () => [],
}),
}));
{
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
testKey.privateKey = privateKey;
testKey.key.publicKey = publicKey
.export({ format: "der", type: "spki" })
.subarray(12)
.toString("base64");
}
const cleanups: Array<() => void> = [];
function tempRoot(prefix: string): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
cleanups.push(() => fs.rmSync(root, { recursive: true, force: true }));
return root;
}
/** Bundled root with one plugin, and an empty user plugins dir. */
function setup() {
const bundled = tempRoot("termix-bundled-");
const data = tempRoot("termix-data-");
const userDir = path.join(data, "plugins");
fs.mkdirSync(userDir, { recursive: true });
createFixturePlugin({ id: "bundled-one", root: bundled });
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
process.env.DATA_DIR = data;
return { userDir };
}
/** Writes <id>-1.0.0.tmxplug into `dir`, optionally with a .sig. */
async function writeArtifact(
dir: string,
id: string,
sig: "valid" | "wrong-key" | "none",
): Promise<string> {
const build = tempRoot("termix-build-");
createFixturePlugin({ id, root: build });
const file = path.join(dir, `${id}-1.0.0.tmxplug`);
await tar.c({ gzip: true, file, cwd: path.join(build, id) }, [
"manifest.json",
"backend",
]);
if (sig !== "none") {
const signer =
sig === "valid"
? testKey.privateKey
: crypto.generateKeyPairSync("ed25519").privateKey;
const digest = crypto
.createHash("sha256")
.update(fs.readFileSync(file))
.digest();
fs.writeFileSync(
`${file}.sig`,
crypto.sign(null, digest, signer).toString("base64"),
);
}
return file;
}
afterEach(() => {
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
delete process.env.DATA_DIR;
delete process.env.TERMIX_REQUIRE_SIGNED_PLUGINS;
while (cleanups.length) cleanups.pop()?.();
});
async function loadedIds(): Promise<string[]> {
const loader = new PluginLoader();
const loaded = await loader.loadAll();
return loaded.map((plugin) => plugin.id).sort();
}
describe("signed plugins gate off", () => {
it("loads a user folder, an unsigned archive and a signed archive", async () => {
const { userDir } = setup();
createFixturePlugin({ id: "folder-one", root: userDir });
await writeArtifact(userDir, "unsigned-one", "none");
await writeArtifact(userDir, "signed-one", "valid");
expect(await loadedIds()).toEqual([
"bundled-one",
"folder-one",
"signed-one",
"unsigned-one",
]);
expect(
fs.existsSync(
path.join(userDir, ".unpacked", "signed-one", "manifest.json"),
),
).toBe(true);
});
it("still blocks an archive whose .sig does not verify", async () => {
const { userDir } = setup();
await writeArtifact(userDir, "forged-one", "wrong-key");
expect(await loadedIds()).toEqual(["bundled-one"]);
});
});
describe("signed plugins gate on", () => {
it("blocks user folders and unsigned archives, keeps bundled plugins", async () => {
const { userDir } = setup();
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true";
createFixturePlugin({ id: "folder-one", root: userDir });
await writeArtifact(userDir, "unsigned-one", "none");
await writeArtifact(userDir, "forged-one", "wrong-key");
await writeArtifact(userDir, "signed-one", "valid");
expect(await loadedIds()).toEqual(["bundled-one", "signed-one"]);
});
it("refuses a signed archive that takes a bundled plugin's id", async () => {
const { userDir } = setup();
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true";
await writeArtifact(userDir, "bundled-one", "valid");
const loader = new PluginLoader();
const loaded = await loader.loadAll();
expect(loaded.map((plugin) => plugin.id)).toEqual(["bundled-one"]);
expect(loader.get("bundled-one")?.source).toBe("bundled");
});
});
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import crypto from "node:crypto";
import {
requireSignedPlugins,
verifyPluginArtifact,
type TrustedPluginKey,
} from "../../plugins/trust.js";
function keyPair(): { key: TrustedPluginKey; privateKey: crypto.KeyObject } {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const raw = publicKey
.export({ format: "der", type: "spki" })
.subarray(12)
.toString("base64");
return { key: { id: "test", publicKey: raw, addedIn: "2.9.0" }, privateKey };
}
function signed(buffer: Buffer, privateKey: crypto.KeyObject) {
const digest = crypto.createHash("sha256").update(buffer).digest();
return {
sha256: digest.toString("hex"),
signature: crypto.sign(null, digest, privateKey).toString("base64"),
};
}
describe("verifyPluginArtifact", () => {
const artifact = Buffer.from("a plugin archive");
it("accepts a file signed by a pinned key", () => {
const { key, privateKey } = keyPair();
const { sha256, signature } = signed(artifact, privateKey);
expect(verifyPluginArtifact(artifact, sha256, signature, [key])).toEqual({
ok: true,
keyId: "test",
});
});
it("accepts a signature from any of several pinned keys", () => {
const old = keyPair();
const current = keyPair();
const { sha256, signature } = signed(artifact, old.privateKey);
const result = verifyPluginArtifact(artifact, sha256, signature, [
current.key,
{ ...old.key, id: "old" },
]);
expect(result).toEqual({ ok: true, keyId: "old" });
});
it("refuses a tampered file", () => {
const { key, privateKey } = keyPair();
const { sha256, signature } = signed(artifact, privateKey);
const tampered = Buffer.from("a plugin archivE");
expect(verifyPluginArtifact(tampered, sha256, signature, [key])).toEqual({
ok: false,
reason: "sha256 does not match the file",
});
});
it("refuses a tampered file even when the sha256 matches it", () => {
const { key, privateKey } = keyPair();
const { signature } = signed(artifact, privateKey);
const tampered = Buffer.from("something else");
const sha256 = crypto.createHash("sha256").update(tampered).digest("hex");
expect(verifyPluginArtifact(tampered, sha256, signature, [key])).toEqual({
ok: false,
reason: "signature does not match a trusted key",
});
});
it("refuses a signature from a key that is not pinned", () => {
const pinned = keyPair();
const other = keyPair();
const { sha256, signature } = signed(artifact, other.privateKey);
expect(
verifyPluginArtifact(artifact, sha256, signature, [pinned.key]).ok,
).toBe(false);
});
it("refuses garbage and an empty key list", () => {
const { key, privateKey } = keyPair();
const { sha256, signature } = signed(artifact, privateKey);
expect(verifyPluginArtifact(artifact, sha256, "bm9wZQ==", [key]).ok).toBe(
false,
);
expect(verifyPluginArtifact(artifact, "zz", signature, [key]).ok).toBe(
false,
);
expect(verifyPluginArtifact(artifact, sha256, signature, []).ok).toBe(
false,
);
});
it("trusts no key by default until one is pinned", () => {
const { privateKey } = keyPair();
const { sha256, signature } = signed(artifact, privateKey);
expect(verifyPluginArtifact(artifact, sha256, signature).ok).toBe(false);
});
});
describe("requireSignedPlugins", () => {
it("is on only for the exact value true", () => {
const before = process.env.TERMIX_REQUIRE_SIGNED_PLUGINS;
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true";
expect(requireSignedPlugins()).toBe(true);
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "1";
expect(requireSignedPlugins()).toBe(false);
if (before === undefined) delete process.env.TERMIX_REQUIRE_SIGNED_PLUGINS;
else process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = before;
});
});