diff --git a/CHANGELOG.md b/CHANGELOG.md index 0508e1f95..03827ed98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 - 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 +- 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 diff --git a/src/backend/database/routes/plugins.ts b/src/backend/database/routes/plugins.ts index 732c96996..2e9b0f7e5 100644 --- a/src/backend/database/routes/plugins.ts +++ b/src/backend/database/routes/plugins.ts @@ -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, diff --git a/src/backend/plugins/manage.ts b/src/backend/plugins/manage.ts index 1a779d611..674bd74a6 100644 --- a/src/backend/plugins/manage.ts +++ b/src/backend/plugins/manage.ts @@ -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(); @@ -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 { @@ -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 { +/** + * 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 { 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 { * 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 { +export async function stageUpload( + buffer: Buffer, + signature: string | null = null, +): Promise { 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 { 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 }; diff --git a/src/backend/tests/plugins/manage.test.ts b/src/backend/tests/plugins/manage.test.ts index a1a4ce826..22512c566 100644 --- a/src/backend/tests/plugins/manage.test.ts +++ b/src/backend/tests/plugins/manage.test.ts @@ -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); diff --git a/src/ui/api/plugins-api.ts b/src/ui/api/plugins-api.ts index 2bec1eaa1..5eb0ea527 100644 --- a/src/ui/api/plugins-api.ts +++ b/src/ui/api/plugins-api.ts @@ -643,11 +643,24 @@ export interface PluginUploadPreview { features?: string[]; capabilities: string[]; replaces: string | null; + official?: boolean; } -export async function uploadPlugin(file: Blob): Promise { +export async function uploadPlugin( + file: Blob, + signature?: string, +): Promise { 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; } diff --git a/src/ui/plugins/PluginsTab.tsx b/src/ui/plugins/PluginsTab.tsx index 61639e178..519843dac 100644 --- a/src/ui/plugins/PluginsTab.tsx +++ b/src/ui/plugins/PluginsTab.tsx @@ -983,12 +983,16 @@ function UploadButton({ manager }: { manager: PluginsManager }) { { - 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); }} />