mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-01 16:15:24 +00:00
fix: accept the release .sig when installing a plugin from a file
This commit is contained in:
1 parent
3def536dcc
commit
b32203d19b
7 files changed
+146
-17
No files matched your search
@@ -30,6 +30,7 @@
|
|||||||
- Adding a certificate on an install where Termix cannot manage nginx says what to set instead of only asking for a restart
|
- Adding a certificate on an install where Termix cannot manage nginx says what to set instead of only asking for a restart
|
||||||
- A login someone shared with you works on a host shared with you, for personal SSH authentication and when the owner shares SSH authentication
|
- A login someone shared with you works on a host shared with you, for personal SSH authentication and when the owner shares SSH authentication
|
||||||
- Lists in plugins (like the AI Assets table) use their full width on narrow screens, keep cells under the right header and no longer overlap rows
|
- Lists in plugins (like the AI Assets table) use their full width on narrow screens, keep cells under the right header and no longer overlap rows
|
||||||
|
- Installing an official plugin from a file works offline: pick the .tmxplug and its .sig from the release together and it installs as official, so plugins like Fleets no longer stop with migration refused. It can also fill in a plugin that was on but could not be downloaded
|
||||||
|
|
||||||
## 26.10.5
|
## 26.10.5
|
||||||
|
|
||||||
|
|||||||
@@ -2706,6 +2706,10 @@ router.put(
|
|||||||
* summary: Upload a .tmxplug to install from a file
|
* summary: Upload a .tmxplug to install from a file
|
||||||
* description: >
|
* description: >
|
||||||
* Developer mode only. The body is the raw .tmxplug (at most 50 MB).
|
* 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
|
* 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
|
* minutes, but nothing is loaded. The answer lists what it asks for so
|
||||||
* the admin can agree, then POST /plugins/upload/{token}/install
|
* the admin can agree, then POST /plugins/upload/{token}/install
|
||||||
@@ -2751,7 +2755,13 @@ router.post(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const { stageUpload } = await import("../../plugins/manage.js");
|
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) {
|
} catch (error) {
|
||||||
sendManageError(
|
sendManageError(
|
||||||
res,
|
res,
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ import {
|
|||||||
pluginFeatures,
|
pluginFeatures,
|
||||||
type PluginManifest,
|
type PluginManifest,
|
||||||
} from "./manifest.js";
|
} from "./manifest.js";
|
||||||
import { requireSignedPlugins } from "./trust.js";
|
import { requireSignedPlugins, verifyAnyTrusted } from "./trust.js";
|
||||||
import {
|
import {
|
||||||
parseChangelog,
|
parseChangelog,
|
||||||
parseReleaseNotes,
|
parseReleaseNotes,
|
||||||
@@ -1525,6 +1525,8 @@ interface StagedUpload {
|
|||||||
file: string;
|
file: string;
|
||||||
manifest: PluginManifest;
|
manifest: PluginManifest;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
|
/** Set when the upload came with a .sig an official key made. */
|
||||||
|
official: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploads = new Map<string, StagedUpload>();
|
const uploads = new Map<string, StagedUpload>();
|
||||||
@@ -1548,6 +1550,29 @@ export interface UploadPreview {
|
|||||||
capabilities: string[];
|
capabilities: string[];
|
||||||
/** The version of an earlier upload this one replaces. */
|
/** The version of an earlier upload this one replaces. */
|
||||||
replaces: string | null;
|
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> {
|
async function assertUploadsAllowed(): Promise<void> {
|
||||||
@@ -1604,9 +1629,17 @@ function validateUpload(buffer: Buffer): PluginManifest {
|
|||||||
return manifest;
|
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();
|
const { loader } = getPluginRuntime();
|
||||||
|
if (official && !loader.get(pluginId)) return null;
|
||||||
if (loader.bundledIds().has(pluginId)) {
|
if (loader.bundledIds().has(pluginId)) {
|
||||||
throw new PluginManageError(
|
throw new PluginManageError(
|
||||||
`${pluginId} ships with Termix and can only be replaced by a signed release`,
|
`${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
|
* Checks an uploaded .tmxplug and holds it until the admin agrees to what
|
||||||
* it asks for. Nothing is loaded yet.
|
* 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 assertUploadsAllowed();
|
||||||
await clearUploads(Date.now());
|
await clearUploads(Date.now());
|
||||||
const manifest = validateUpload(buffer);
|
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 token = crypto.randomBytes(16).toString("hex");
|
||||||
const dir = getDownloadsDir();
|
const dir = getDownloadsDir();
|
||||||
await fs.promises.mkdir(dir, { recursive: true });
|
await fs.promises.mkdir(dir, { recursive: true });
|
||||||
const file = path.join(dir, `upload-${token}.tmxplug`);
|
const file = path.join(dir, `upload-${token}.tmxplug`);
|
||||||
await fs.promises.writeFile(file, buffer);
|
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, {
|
uploads.set(token, {
|
||||||
file,
|
file,
|
||||||
manifest,
|
manifest,
|
||||||
expiresAt: Date.now() + UPLOAD_TTL_MS,
|
expiresAt: Date.now() + UPLOAD_TTL_MS,
|
||||||
|
official,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1658,6 +1700,7 @@ export async function stageUpload(buffer: Buffer): Promise<UploadPreview> {
|
|||||||
features: pluginFeatures(manifest.features),
|
features: pluginFeatures(manifest.features),
|
||||||
capabilities: [...manifest.capabilities],
|
capabilities: [...manifest.capabilities],
|
||||||
replaces,
|
replaces,
|
||||||
|
official,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1687,7 +1730,7 @@ export function installUpload(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
assertConsent(options.capabilities, manifest.capabilities);
|
assertConsent(options.capabilities, manifest.capabilities);
|
||||||
await assertReplaceable(pluginId);
|
await assertReplaceable(pluginId, upload.official);
|
||||||
|
|
||||||
const { createCurrentPluginRepository } = await repos();
|
const { createCurrentPluginRepository } = await repos();
|
||||||
const repository = createCurrentPluginRepository();
|
const repository = createCurrentPluginRepository();
|
||||||
@@ -1700,6 +1743,7 @@ export function installUpload(
|
|||||||
plugin = await swapIn(pluginId, { upload: upload.file });
|
plugin = await swapIn(pluginId, { upload: upload.file });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await fs.promises.rm(upload.file, { force: true });
|
await fs.promises.rm(upload.file, { force: true });
|
||||||
|
await fs.promises.rm(`${upload.file}.sig`, { force: true });
|
||||||
const { loader } = getPluginRuntime();
|
const { loader } = getPluginRuntime();
|
||||||
if (before && wasEnabled && loader.get(pluginId)) {
|
if (before && wasEnabled && loader.get(pluginId)) {
|
||||||
await startAndRecord(pluginId);
|
await startAndRecord(pluginId);
|
||||||
@@ -1712,7 +1756,7 @@ export function installUpload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await repository.update(pluginId, {
|
await repository.update(pluginId, {
|
||||||
tier: UNVERIFIED_TIER,
|
tier: upload.official ? "official" : UNVERIFIED_TIER,
|
||||||
registryId: null,
|
registryId: null,
|
||||||
pinnedVersion: null,
|
pinnedVersion: null,
|
||||||
autoUpdate: false,
|
autoUpdate: false,
|
||||||
@@ -1736,7 +1780,7 @@ export function installUpload(
|
|||||||
}
|
}
|
||||||
const state = await startAndRecord(pluginId);
|
const state = await startAndRecord(pluginId);
|
||||||
pluginLogger.warn(
|
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" },
|
{ operation: "plugin_install" },
|
||||||
);
|
);
|
||||||
return { id: pluginId, version: manifest.version, state };
|
return { id: pluginId, version: manifest.version, state };
|
||||||
|
|||||||
@@ -902,6 +902,57 @@ describe("installing from a file", () => {
|
|||||||
expect(db.rows.get("notes")?.tier).toBe(manage.UNVERIFIED_TIER);
|
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 () => {
|
it("refuses a file that is not a plugin archive", async () => {
|
||||||
await boot();
|
await boot();
|
||||||
await manage.setDeveloperMode(true);
|
await manage.setDeveloperMode(true);
|
||||||
|
|||||||
@@ -643,11 +643,24 @@ export interface PluginUploadPreview {
|
|||||||
features?: string[];
|
features?: string[];
|
||||||
capabilities: string[];
|
capabilities: string[];
|
||||||
replaces: string | null;
|
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, {
|
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;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -983,12 +983,16 @@ function UploadButton({ manager }: { manager: PluginsManager }) {
|
|||||||
<input
|
<input
|
||||||
ref={input}
|
ref={input}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".tmxplug"
|
accept=".tmxplug,.sig"
|
||||||
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
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 = "";
|
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
|
<Button
|
||||||
|
|||||||
@@ -333,11 +333,17 @@ export function usePluginsManager() {
|
|||||||
[run, t, failToast],
|
[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(
|
const requestUpload = useCallback(
|
||||||
async (file: File) => {
|
async (file: File, signatureFile?: File) => {
|
||||||
try {
|
try {
|
||||||
const preview = await uploadPlugin(file);
|
const preview = await uploadPlugin(
|
||||||
|
file,
|
||||||
|
signatureFile ? await signatureFile.text() : undefined,
|
||||||
|
);
|
||||||
setConsent({
|
setConsent({
|
||||||
plugin: uploadEntry(preview),
|
plugin: uploadEntry(preview),
|
||||||
mode: "upload",
|
mode: "upload",
|
||||||
|
|||||||
Reference in new issue
Block a user