mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05: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
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user