fix: accept the release .sig when installing a plugin from a file

This commit is contained in:
LukeGus committed 2026-10-29 14:36:01 -05:00
1 parent 3def536dcc
commit b32203d19b
7 files changed
+146 -17

No files matched your search

+11 -1
View File
@@ -2706,6 +2706,10 @@ router.put(
* summary: Upload a .tmxplug to install from a file
* description: >
* Developer mode only. The body is the raw .tmxplug (at most 50 MB).
The release's .sig may come base64 encoded in the
X-Termix-Plugin-Signature header; an official one makes the file
install as an official plugin, and it may then fill in a plugin
whose download failed.
* The file is checked (archive, manifest, plugin API) and held for 30
* minutes, but nothing is loaded. The answer lists what it asks for so
* the admin can agree, then POST /plugins/upload/{token}/install
@@ -2751,7 +2755,13 @@ router.post(
}
try {
const { stageUpload } = await import("../../plugins/manage.js");
res.json(await stageUpload(req.body));
// The release's .sig, base64 encoded, so an official file installs as
// official (and may write the tables its 2.8 version left).
const header = req.get("x-termix-plugin-signature");
const signature = header
? Buffer.from(header, "base64").toString("utf8")
: null;
res.json(await stageUpload(req.body, signature));
} catch (error) {
sendManageError(
res,
+52 -8
View File
@@ -73,7 +73,7 @@ import {
pluginFeatures,
type PluginManifest,
} from "./manifest.js";
import { requireSignedPlugins } from "./trust.js";
import { requireSignedPlugins, verifyAnyTrusted } from "./trust.js";
import {
parseChangelog,
parseReleaseNotes,
@@ -1525,6 +1525,8 @@ interface StagedUpload {
file: string;
manifest: PluginManifest;
expiresAt: number;
/** Set when the upload came with a .sig an official key made. */
official: boolean;
}
const uploads = new Map<string, StagedUpload>();
@@ -1548,6 +1550,29 @@ export interface UploadPreview {
capabilities: string[];
/** The version of an earlier upload this one replaces. */
replaces: string | null;
/** The .sig sent with it was made by the official Termix key. */
official: boolean;
}
/**
* Checks a .sig sent with an upload. An official signature makes the file
* the same as a registry download, so it may stand in for one that could not
* be fetched (an offline install). Anything else is refused.
*/
function checkUploadSignature(
buffer: Buffer,
signature: string,
): { official: boolean } {
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
const result = verifyAnyTrusted(buffer, sha256, signature);
if (result.ok === false) {
throw new PluginManageError(
`The signature does not match this file: ${result.reason}`,
400,
"INVALID_SIGNATURE",
);
}
return { official: result.tier === "official" };
}
async function assertUploadsAllowed(): Promise<void> {
@@ -1604,9 +1629,17 @@ function validateUpload(buffer: Buffer): PluginManifest {
return manifest;
}
/** Only another upload of the same id can be replaced from a file. */
async function assertReplaceable(pluginId: string): Promise<string | null> {
/**
* Only another upload of the same id can be replaced from a file. An
* officially signed file may also fill in a plugin whose code is missing, like
* one that was on but could not be downloaded.
*/
async function assertReplaceable(
pluginId: string,
official = false,
): Promise<string | null> {
const { loader } = getPluginRuntime();
if (official && !loader.get(pluginId)) return null;
if (loader.bundledIds().has(pluginId)) {
throw new PluginManageError(
`${pluginId} ships with Termix and can only be replaced by a signed release`,
@@ -1631,21 +1664,30 @@ async function assertReplaceable(pluginId: string): Promise<string | null> {
* Checks an uploaded .tmxplug and holds it until the admin agrees to what
* it asks for. Nothing is loaded yet.
*/
export async function stageUpload(buffer: Buffer): Promise<UploadPreview> {
export async function stageUpload(
buffer: Buffer,
signature: string | null = null,
): Promise<UploadPreview> {
await assertUploadsAllowed();
await clearUploads(Date.now());
const manifest = validateUpload(buffer);
const replaces = await assertReplaceable(manifest.id);
const { official } = signature
? checkUploadSignature(buffer, signature)
: { official: false };
const replaces = await assertReplaceable(manifest.id, official);
const token = crypto.randomBytes(16).toString("hex");
const dir = getDownloadsDir();
await fs.promises.mkdir(dir, { recursive: true });
const file = path.join(dir, `upload-${token}.tmxplug`);
await fs.promises.writeFile(file, buffer);
// Next to the file, where the loader looks for it.
if (signature) await fs.promises.writeFile(`${file}.sig`, signature);
uploads.set(token, {
file,
manifest,
expiresAt: Date.now() + UPLOAD_TTL_MS,
official,
});
return {
@@ -1658,6 +1700,7 @@ export async function stageUpload(buffer: Buffer): Promise<UploadPreview> {
features: pluginFeatures(manifest.features),
capabilities: [...manifest.capabilities],
replaces,
official,
};
}
@@ -1687,7 +1730,7 @@ export function installUpload(
);
}
assertConsent(options.capabilities, manifest.capabilities);
await assertReplaceable(pluginId);
await assertReplaceable(pluginId, upload.official);
const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository();
@@ -1700,6 +1743,7 @@ export function installUpload(
plugin = await swapIn(pluginId, { upload: upload.file });
} catch (error) {
await fs.promises.rm(upload.file, { force: true });
await fs.promises.rm(`${upload.file}.sig`, { force: true });
const { loader } = getPluginRuntime();
if (before && wasEnabled && loader.get(pluginId)) {
await startAndRecord(pluginId);
@@ -1712,7 +1756,7 @@ export function installUpload(
}
await repository.update(pluginId, {
tier: UNVERIFIED_TIER,
tier: upload.official ? "official" : UNVERIFIED_TIER,
registryId: null,
pinnedVersion: null,
autoUpdate: false,
@@ -1736,7 +1780,7 @@ export function installUpload(
}
const state = await startAndRecord(pluginId);
pluginLogger.warn(
`Installed unverified plugin ${pluginId}@${manifest.version} from a file`,
`Installed ${upload.official ? "officially signed" : "unverified"} plugin ${pluginId}@${manifest.version} from a file`,
{ operation: "plugin_install" },
);
return { id: pluginId, version: manifest.version, state };
+51
View File
@@ -902,6 +902,57 @@ describe("installing from a file", () => {
expect(db.rows.get("notes")?.tier).toBe(manage.UNVERIFIED_TIER);
});
it("refuses a signature that does not match the file", async () => {
await boot();
await manage.setDeveloperMode(true);
await expect(
manage.stageUpload(await artifactBuffer("notes"), "bm90IGEgc2ln"),
).rejects.toMatchObject({ code: "INVALID_SIGNATURE" });
});
it("keeps a signature from a non official key unverified", async () => {
const { setCustomPluginKeys } = await import("../../plugins/trust.js");
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
setCustomPluginKeys([
{
id: "mine",
publicKey: publicKey
.export({ format: "der", type: "spki" })
.subarray(12)
.toString("base64"),
addedIn: "26.10.6",
},
]);
try {
await boot();
await manage.setDeveloperMode(true);
setCustomPluginKeys([
{
id: "mine",
publicKey: publicKey
.export({ format: "der", type: "spki" })
.subarray(12)
.toString("base64"),
addedIn: "26.10.6",
},
]);
const buffer = await artifactBuffer("notes");
const digest = crypto.createHash("sha256").update(buffer).digest();
const signature = crypto
.sign(null, digest, privateKey)
.toString("base64");
const preview = await manage.stageUpload(buffer, signature);
expect(preview.official).toBe(false);
await manage.installUpload(preview.token, {
userId: "admin",
capabilities: preview.capabilities,
});
expect(db.rows.get("notes")?.tier).toBe(manage.UNVERIFIED_TIER);
} finally {
setCustomPluginKeys([]);
}
});
it("refuses a file that is not a plugin archive", async () => {
await boot();
await manage.setDeveloperMode(true);
+15 -2
View File
@@ -643,11 +643,24 @@ export interface PluginUploadPreview {
features?: string[];
capabilities: string[];
replaces: string | null;
official?: boolean;
}
export async function uploadPlugin(file: Blob): Promise<PluginUploadPreview> {
export async function uploadPlugin(
file: Blob,
signature?: string,
): Promise<PluginUploadPreview> {
const response = await rbacApi.post("/plugins/upload", file, {
headers: { "Content-Type": "application/octet-stream" },
headers: {
"Content-Type": "application/octet-stream",
...(signature
? {
"X-Termix-Plugin-Signature": btoa(
String.fromCharCode(...new TextEncoder().encode(signature)),
),
}
: {}),
},
});
return response.data;
}
+7 -3
View File
@@ -983,12 +983,16 @@ function UploadButton({ manager }: { manager: PluginsManager }) {
<input
ref={input}
type="file"
accept=".tmxplug"
accept=".tmxplug,.sig"
multiple
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
// A release's .sig can be picked with it, so it installs as official.
const files = [...(e.target.files ?? [])];
e.target.value = "";
if (file) void manager.requestUpload(file);
const file = files.find((f) => f.name.endsWith(".tmxplug"));
const signature = files.find((f) => f.name.endsWith(".sig"));
if (file) void manager.requestUpload(file, signature);
}}
/>
<Button
+9 -3
View File
@@ -333,11 +333,17 @@ export function usePluginsManager() {
[run, t, failToast],
);
/** Reads a picked .tmxplug and asks for consent before anything loads. */
/**
* Reads a picked .tmxplug, with its .sig when one was picked too, and asks
* for consent before anything loads.
*/
const requestUpload = useCallback(
async (file: File) => {
async (file: File, signatureFile?: File) => {
try {
const preview = await uploadPlugin(file);
const preview = await uploadPlugin(
file,
signatureFile ? await signatureFile.text() : undefined,
);
setConsent({
plugin: uploadEntry(preview),
mode: "upload",