mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: add plugin packing, signing, pinned trust and tmxplug bundling
This commit is contained in:
1 parent
c6a44b7ff3
commit
2ee9b3592c
25 files changed
+1764
-56
No files matched your search
@@ -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/*
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+17
-1
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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).
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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"),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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"/);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,9 +12,11 @@
|
||||
* impossible. See ARCHITECTURE.md.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import semver from "semver";
|
||||
import * as tar from "tar";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { parseManifest } from "./manifest.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
@@ -23,7 +25,9 @@ import {
|
||||
getPluginBackendEntry,
|
||||
getPluginManifestPath,
|
||||
getPluginsDir,
|
||||
getUnpackedPluginsDir,
|
||||
} from "./paths.js";
|
||||
import { requireSignedPlugins, verifyPluginArtifact } from "./trust.js";
|
||||
import {
|
||||
createPluginContext,
|
||||
createPluginHandle,
|
||||
@@ -193,16 +197,35 @@ export class PluginLoader {
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const isArtifact =
|
||||
source === "user" &&
|
||||
entry.isFile() &&
|
||||
entry.name.endsWith(".tmxplug");
|
||||
if (!entry.isDirectory() && !isArtifact) continue;
|
||||
if (entry.isDirectory() && entry.name.startsWith(".")) continue;
|
||||
|
||||
const dir = path.join(root, entry.name);
|
||||
try {
|
||||
if (isArtifact) {
|
||||
loaded.push(await this.loadArtifact(dir, bundledIds));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source === "user" && bundledIds.has(entry.name)) {
|
||||
throw new Error(
|
||||
`a bundled plugin already uses the id "${entry.name}"; a plugin in the data directory cannot replace it`,
|
||||
);
|
||||
}
|
||||
|
||||
// A folder carries no signature, so with signing required only a
|
||||
// signed .tmxplug can install a plugin. Bundled plugins are trusted
|
||||
// by shipping in the image.
|
||||
if (source === "user" && requireSignedPlugins()) {
|
||||
throw new Error(
|
||||
"TERMIX_REQUIRE_SIGNED_PLUGINS is on and a plugin folder carries no signature; install it as a signed .tmxplug",
|
||||
);
|
||||
}
|
||||
|
||||
const plugin = await this.load(dir, source);
|
||||
loaded.push(plugin);
|
||||
} catch (error) {
|
||||
@@ -218,6 +241,78 @@ export class PluginLoader {
|
||||
return loaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a user-installed <name>.tmxplug. A .sig next to it must verify
|
||||
* against a pinned key; with TERMIX_REQUIRE_SIGNED_PLUGINS=true it must
|
||||
* also exist. The archive is unpacked fresh on every load, so the file
|
||||
* stays the only source of truth.
|
||||
*/
|
||||
async loadArtifact(
|
||||
file: string,
|
||||
bundledIds: ReadonlySet<string> = new Set(),
|
||||
): Promise<LoadedPlugin> {
|
||||
const buffer = await fs.promises.readFile(file);
|
||||
const sigFile = `${file}.sig`;
|
||||
|
||||
if (fs.existsSync(sigFile)) {
|
||||
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
|
||||
const result = verifyPluginArtifact(
|
||||
buffer,
|
||||
sha256,
|
||||
await fs.promises.readFile(sigFile, "utf8"),
|
||||
);
|
||||
if (result.ok === false) {
|
||||
throw new Error(`signature check failed: ${result.reason}`);
|
||||
}
|
||||
} else if (requireSignedPlugins()) {
|
||||
throw new Error(
|
||||
`TERMIX_REQUIRE_SIGNED_PLUGINS is on and ${path.basename(file)} has no .sig next to it`,
|
||||
);
|
||||
}
|
||||
|
||||
const unpackedRoot = getUnpackedPluginsDir();
|
||||
const staging = path.join(
|
||||
unpackedRoot,
|
||||
`.staging-${crypto.randomBytes(6).toString("hex")}`,
|
||||
);
|
||||
await fs.promises.mkdir(staging, { recursive: true });
|
||||
try {
|
||||
await tar.x({
|
||||
file,
|
||||
cwd: staging,
|
||||
strict: true,
|
||||
// Plain files and folders only. tar already refuses absolute and
|
||||
// ".." paths unless preservePaths is set.
|
||||
filter: (_entryPath, entry) =>
|
||||
"type" in entry &&
|
||||
(entry.type === "File" || entry.type === "Directory"),
|
||||
});
|
||||
|
||||
const raw = JSON.parse(
|
||||
await fs.promises.readFile(getPluginManifestPath(staging), "utf8"),
|
||||
);
|
||||
const id = typeof raw?.id === "string" ? raw.id : "";
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
|
||||
throw new Error(`${path.basename(file)} has no valid manifest id`);
|
||||
}
|
||||
if (bundledIds.has(id)) {
|
||||
throw new Error(
|
||||
`a bundled plugin already uses the id "${id}"; a plugin in the data directory cannot replace it`,
|
||||
);
|
||||
}
|
||||
if (this.plugins.has(id)) {
|
||||
throw new Error(`another plugin already uses the id "${id}"`);
|
||||
}
|
||||
|
||||
const target = path.join(unpackedRoot, id);
|
||||
await fs.promises.rm(target, { recursive: true, force: true });
|
||||
await fs.promises.rename(staging, target);
|
||||
return await this.load(target, "user");
|
||||
} finally {
|
||||
await fs.promises.rm(staging, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders plugins so a dependency always activates before its dependents.
|
||||
*
|
||||
|
||||
@@ -15,6 +15,14 @@ export function getPluginsDir(): string {
|
||||
return path.join(process.env.DATA_DIR || "./db/data", "plugins");
|
||||
}
|
||||
|
||||
/**
|
||||
* Where user .tmxplug files are unpacked. A dot directory inside the user
|
||||
* plugins dir, which the loader's directory scan skips.
|
||||
*/
|
||||
export function getUnpackedPluginsDir(): string {
|
||||
return path.join(getPluginsDir(), ".unpacked");
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the plugins that ship with Termix live.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export interface TrustedPluginKey {
|
||||
/** First 16 hex chars of the sha256 of the raw public key. */
|
||||
id: string;
|
||||
/** Raw 32-byte Ed25519 public key, base64. */
|
||||
publicKey: string;
|
||||
/** The Termix release that started trusting it. */
|
||||
addedIn: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry signing keys this build trusts. Compile-time on purpose: a
|
||||
* key read from a registry index would let whoever serves the index sign
|
||||
* anything.
|
||||
*
|
||||
* Rotation: add the new key here and ship a release, start signing with it,
|
||||
* then remove the old key one release cycle later so installs that update
|
||||
* late still verify plugins signed before the switch.
|
||||
*
|
||||
* Add a key with `termix-plugin keygen` (see the Termix-Registry README).
|
||||
*/
|
||||
export const TRUSTED_PLUGIN_KEYS: readonly TrustedPluginKey[] = [];
|
||||
|
||||
export type ArtifactVerification =
|
||||
{ ok: true; keyId: string } | { ok: false; reason: string };
|
||||
|
||||
// DER prefix of an Ed25519 SubjectPublicKeyInfo; the raw key follows.
|
||||
const SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
||||
|
||||
function toPublicKey(base64: string): crypto.KeyObject | null {
|
||||
const raw = Buffer.from(base64, "base64");
|
||||
if (raw.length !== 32) return null;
|
||||
try {
|
||||
return crypto.createPublicKey({
|
||||
key: Buffer.concat([SPKI_PREFIX, raw]),
|
||||
format: "der",
|
||||
type: "spki",
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that `buffer` hashes to `sha256` (hex) and that `signature`
|
||||
* (base64 Ed25519 over the raw digest) was made by a pinned key.
|
||||
*
|
||||
* `keys` exists for tests. Production callers never pass it.
|
||||
*/
|
||||
export function verifyPluginArtifact(
|
||||
buffer: Buffer,
|
||||
sha256: string,
|
||||
signature: string,
|
||||
keys: readonly TrustedPluginKey[] = TRUSTED_PLUGIN_KEYS,
|
||||
): ArtifactVerification {
|
||||
const digest = crypto.createHash("sha256").update(buffer).digest();
|
||||
const expected = Buffer.from(sha256.trim().toLowerCase(), "hex");
|
||||
if (
|
||||
expected.length !== digest.length ||
|
||||
!crypto.timingSafeEqual(expected, digest)
|
||||
) {
|
||||
return { ok: false, reason: "sha256 does not match the file" };
|
||||
}
|
||||
|
||||
const sig = Buffer.from(signature.trim(), "base64");
|
||||
if (sig.length !== 64) {
|
||||
return { ok: false, reason: "signature is not a valid Ed25519 signature" };
|
||||
}
|
||||
if (keys.length === 0) {
|
||||
return { ok: false, reason: "this build trusts no signing keys" };
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const publicKey = toPublicKey(key.publicKey);
|
||||
if (publicKey && crypto.verify(null, digest, publicKey, sig)) {
|
||||
return { ok: true, keyId: key.id };
|
||||
}
|
||||
}
|
||||
return { ok: false, reason: "signature does not match a trusted key" };
|
||||
}
|
||||
|
||||
/** TERMIX_REQUIRE_SIGNED_PLUGINS=true blocks unsigned user plugins. */
|
||||
export function requireSignedPlugins(): boolean {
|
||||
return process.env.TERMIX_REQUIRE_SIGNED_PLUGINS === "true";
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* TERMIX_REQUIRE_SIGNED_PLUGINS against the real loader and real archives.
|
||||
* The pinned key list is swapped for a test key; nothing else is mocked.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
import { createFixturePlugin } from "./fixture-plugin.js";
|
||||
import { PluginLoader } from "../../plugins/loader.js";
|
||||
|
||||
const testKey = vi.hoisted(() => ({
|
||||
privateKey: null as crypto.KeyObject | null,
|
||||
key: { id: "test", publicKey: "", addedIn: "2.9.0" },
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/trust.js", async (importActual) => {
|
||||
const actual = await importActual<typeof import("../../plugins/trust.js")>();
|
||||
return {
|
||||
...actual,
|
||||
verifyPluginArtifact: (buffer: Buffer, sha256: string, signature: string) =>
|
||||
actual.verifyPluginArtifact(buffer, sha256, signature, [testKey.key]),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginStorageRepository: () => ({
|
||||
get: async () => null,
|
||||
set: async () => {},
|
||||
delete: async () => false,
|
||||
listKeys: async () => [],
|
||||
}),
|
||||
}));
|
||||
|
||||
{
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
testKey.privateKey = privateKey;
|
||||
testKey.key.publicKey = publicKey
|
||||
.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64");
|
||||
}
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
function tempRoot(prefix: string): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
cleanups.push(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
|
||||
/** Bundled root with one plugin, and an empty user plugins dir. */
|
||||
function setup() {
|
||||
const bundled = tempRoot("termix-bundled-");
|
||||
const data = tempRoot("termix-data-");
|
||||
const userDir = path.join(data, "plugins");
|
||||
fs.mkdirSync(userDir, { recursive: true });
|
||||
createFixturePlugin({ id: "bundled-one", root: bundled });
|
||||
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
|
||||
process.env.DATA_DIR = data;
|
||||
return { userDir };
|
||||
}
|
||||
|
||||
/** Writes <id>-1.0.0.tmxplug into `dir`, optionally with a .sig. */
|
||||
async function writeArtifact(
|
||||
dir: string,
|
||||
id: string,
|
||||
sig: "valid" | "wrong-key" | "none",
|
||||
): Promise<string> {
|
||||
const build = tempRoot("termix-build-");
|
||||
createFixturePlugin({ id, root: build });
|
||||
const file = path.join(dir, `${id}-1.0.0.tmxplug`);
|
||||
await tar.c({ gzip: true, file, cwd: path.join(build, id) }, [
|
||||
"manifest.json",
|
||||
"backend",
|
||||
]);
|
||||
if (sig !== "none") {
|
||||
const signer =
|
||||
sig === "valid"
|
||||
? testKey.privateKey
|
||||
: crypto.generateKeyPairSync("ed25519").privateKey;
|
||||
const digest = crypto
|
||||
.createHash("sha256")
|
||||
.update(fs.readFileSync(file))
|
||||
.digest();
|
||||
fs.writeFileSync(
|
||||
`${file}.sig`,
|
||||
crypto.sign(null, digest, signer).toString("base64"),
|
||||
);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.TERMIX_REQUIRE_SIGNED_PLUGINS;
|
||||
while (cleanups.length) cleanups.pop()?.();
|
||||
});
|
||||
|
||||
async function loadedIds(): Promise<string[]> {
|
||||
const loader = new PluginLoader();
|
||||
const loaded = await loader.loadAll();
|
||||
return loaded.map((plugin) => plugin.id).sort();
|
||||
}
|
||||
|
||||
describe("signed plugins gate off", () => {
|
||||
it("loads a user folder, an unsigned archive and a signed archive", async () => {
|
||||
const { userDir } = setup();
|
||||
createFixturePlugin({ id: "folder-one", root: userDir });
|
||||
await writeArtifact(userDir, "unsigned-one", "none");
|
||||
await writeArtifact(userDir, "signed-one", "valid");
|
||||
|
||||
expect(await loadedIds()).toEqual([
|
||||
"bundled-one",
|
||||
"folder-one",
|
||||
"signed-one",
|
||||
"unsigned-one",
|
||||
]);
|
||||
expect(
|
||||
fs.existsSync(
|
||||
path.join(userDir, ".unpacked", "signed-one", "manifest.json"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("still blocks an archive whose .sig does not verify", async () => {
|
||||
const { userDir } = setup();
|
||||
await writeArtifact(userDir, "forged-one", "wrong-key");
|
||||
expect(await loadedIds()).toEqual(["bundled-one"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signed plugins gate on", () => {
|
||||
it("blocks user folders and unsigned archives, keeps bundled plugins", async () => {
|
||||
const { userDir } = setup();
|
||||
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true";
|
||||
createFixturePlugin({ id: "folder-one", root: userDir });
|
||||
await writeArtifact(userDir, "unsigned-one", "none");
|
||||
await writeArtifact(userDir, "forged-one", "wrong-key");
|
||||
await writeArtifact(userDir, "signed-one", "valid");
|
||||
|
||||
expect(await loadedIds()).toEqual(["bundled-one", "signed-one"]);
|
||||
});
|
||||
|
||||
it("refuses a signed archive that takes a bundled plugin's id", async () => {
|
||||
const { userDir } = setup();
|
||||
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true";
|
||||
await writeArtifact(userDir, "bundled-one", "valid");
|
||||
const loader = new PluginLoader();
|
||||
const loaded = await loader.loadAll();
|
||||
expect(loaded.map((plugin) => plugin.id)).toEqual(["bundled-one"]);
|
||||
expect(loader.get("bundled-one")?.source).toBe("bundled");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
requireSignedPlugins,
|
||||
verifyPluginArtifact,
|
||||
type TrustedPluginKey,
|
||||
} from "../../plugins/trust.js";
|
||||
|
||||
function keyPair(): { key: TrustedPluginKey; privateKey: crypto.KeyObject } {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
const raw = publicKey
|
||||
.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64");
|
||||
return { key: { id: "test", publicKey: raw, addedIn: "2.9.0" }, privateKey };
|
||||
}
|
||||
|
||||
function signed(buffer: Buffer, privateKey: crypto.KeyObject) {
|
||||
const digest = crypto.createHash("sha256").update(buffer).digest();
|
||||
return {
|
||||
sha256: digest.toString("hex"),
|
||||
signature: crypto.sign(null, digest, privateKey).toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("verifyPluginArtifact", () => {
|
||||
const artifact = Buffer.from("a plugin archive");
|
||||
|
||||
it("accepts a file signed by a pinned key", () => {
|
||||
const { key, privateKey } = keyPair();
|
||||
const { sha256, signature } = signed(artifact, privateKey);
|
||||
expect(verifyPluginArtifact(artifact, sha256, signature, [key])).toEqual({
|
||||
ok: true,
|
||||
keyId: "test",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a signature from any of several pinned keys", () => {
|
||||
const old = keyPair();
|
||||
const current = keyPair();
|
||||
const { sha256, signature } = signed(artifact, old.privateKey);
|
||||
const result = verifyPluginArtifact(artifact, sha256, signature, [
|
||||
current.key,
|
||||
{ ...old.key, id: "old" },
|
||||
]);
|
||||
expect(result).toEqual({ ok: true, keyId: "old" });
|
||||
});
|
||||
|
||||
it("refuses a tampered file", () => {
|
||||
const { key, privateKey } = keyPair();
|
||||
const { sha256, signature } = signed(artifact, privateKey);
|
||||
const tampered = Buffer.from("a plugin archivE");
|
||||
expect(verifyPluginArtifact(tampered, sha256, signature, [key])).toEqual({
|
||||
ok: false,
|
||||
reason: "sha256 does not match the file",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a tampered file even when the sha256 matches it", () => {
|
||||
const { key, privateKey } = keyPair();
|
||||
const { signature } = signed(artifact, privateKey);
|
||||
const tampered = Buffer.from("something else");
|
||||
const sha256 = crypto.createHash("sha256").update(tampered).digest("hex");
|
||||
expect(verifyPluginArtifact(tampered, sha256, signature, [key])).toEqual({
|
||||
ok: false,
|
||||
reason: "signature does not match a trusted key",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a signature from a key that is not pinned", () => {
|
||||
const pinned = keyPair();
|
||||
const other = keyPair();
|
||||
const { sha256, signature } = signed(artifact, other.privateKey);
|
||||
expect(
|
||||
verifyPluginArtifact(artifact, sha256, signature, [pinned.key]).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses garbage and an empty key list", () => {
|
||||
const { key, privateKey } = keyPair();
|
||||
const { sha256, signature } = signed(artifact, privateKey);
|
||||
expect(verifyPluginArtifact(artifact, sha256, "bm9wZQ==", [key]).ok).toBe(
|
||||
false,
|
||||
);
|
||||
expect(verifyPluginArtifact(artifact, "zz", signature, [key]).ok).toBe(
|
||||
false,
|
||||
);
|
||||
expect(verifyPluginArtifact(artifact, sha256, signature, []).ok).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("trusts no key by default until one is pinned", () => {
|
||||
const { privateKey } = keyPair();
|
||||
const { sha256, signature } = signed(artifact, privateKey);
|
||||
expect(verifyPluginArtifact(artifact, sha256, signature).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireSignedPlugins", () => {
|
||||
it("is on only for the exact value true", () => {
|
||||
const before = process.env.TERMIX_REQUIRE_SIGNED_PLUGINS;
|
||||
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true";
|
||||
expect(requireSignedPlugins()).toBe(true);
|
||||
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "1";
|
||||
expect(requireSignedPlugins()).toBe(false);
|
||||
if (before === undefined) delete process.env.TERMIX_REQUIRE_SIGNED_PLUGINS;
|
||||
else process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = before;
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user