From 63067d0e2d3eaa1c2ed184e4e0ac94d103fbee3d Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 23 Jul 2025 11:22:26 -0800 Subject: [PATCH 1/7] Break out download code The main thing here is to pass in an Axios client instead of the SDK client since this does not need to make API calls and we will need to pass a separate client without headers when downloading external signatures. Otherwise the structure remains the same. Some variables are renamed due to being in a new context and some strings messages are simplified. --- src/storage.ts | 244 ++++++++++++++++++++++++++----------------------- 1 file changed, 130 insertions(+), 114 deletions(-) diff --git a/src/storage.ts b/src/storage.ts index 206dbce3..0d249dd8 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,3 +1,4 @@ +import type { AxiosInstance, AxiosRequestConfig } from "axios"; import { Api } from "coder/site/src/api/api"; import { createWriteStream } from "fs"; import fs from "fs/promises"; @@ -202,122 +203,22 @@ export class Storage { const etag = stat !== undefined ? await cli.eTag(binPath) : ""; this.output.info("Using ETag", etag); - // Make the download request. - const controller = new AbortController(); - const resp = await restClient.getAxiosInstance().get(binSource, { - signal: controller.signal, - baseURL: baseUrl, - responseType: "stream", - headers: { - "Accept-Encoding": "gzip", - "If-None-Match": `"${etag}"`, - }, - decompress: true, - // Ignore all errors so we can catch a 404! - validateStatus: () => true, + // Download the binary to a temporary file. + await fs.mkdir(path.dirname(binPath), { recursive: true }); + const tempFile = + binPath + ".temp-" + Math.random().toString(36).substring(8); + const writeStream = createWriteStream(tempFile, { + autoClose: true, + mode: 0o755, + }); + const client = restClient.getAxiosInstance(); + const status = await this.download(client, binSource, writeStream, { + "Accept-Encoding": "gzip", + "If-None-Match": `"${etag}"`, }); - this.output.info("Got status code", resp.status); - switch (resp.status) { + switch (status) { case 200: { - const rawContentLength = resp.headers["content-length"]; - const contentLength = Number.parseInt(rawContentLength); - if (Number.isNaN(contentLength)) { - this.output.warn( - "Got invalid or missing content length", - rawContentLength, - ); - } else { - this.output.info("Got content length", prettyBytes(contentLength)); - } - - // Download to a temporary file. - await fs.mkdir(path.dirname(binPath), { recursive: true }); - const tempFile = - binPath + ".temp-" + Math.random().toString(36).substring(8); - - // Track how many bytes were written. - let written = 0; - - const completed = await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: `Downloading ${buildInfo.version} from ${baseUrl} to ${binPath}`, - cancellable: true, - }, - async (progress, token) => { - const readStream = resp.data as IncomingMessage; - let cancelled = false; - token.onCancellationRequested(() => { - controller.abort(); - readStream.destroy(); - cancelled = true; - }); - - // Reverse proxies might not always send a content length. - const contentLengthPretty = Number.isNaN(contentLength) - ? "unknown" - : prettyBytes(contentLength); - - // Pipe data received from the request to the temp file. - const writeStream = createWriteStream(tempFile, { - autoClose: true, - mode: 0o755, - }); - readStream.on("data", (buffer: Buffer) => { - writeStream.write(buffer, () => { - written += buffer.byteLength; - progress.report({ - message: `${prettyBytes(written)} / ${contentLengthPretty}`, - increment: Number.isNaN(contentLength) - ? undefined - : (buffer.byteLength / contentLength) * 100, - }); - }); - }); - - // Wait for the stream to end or error. - return new Promise((resolve, reject) => { - writeStream.on("error", (error) => { - readStream.destroy(); - reject( - new Error( - `Unable to download binary: ${errToStr(error, "no reason given")}`, - ), - ); - }); - readStream.on("error", (error) => { - writeStream.close(); - reject( - new Error( - `Unable to download binary: ${errToStr(error, "no reason given")}`, - ), - ); - }); - readStream.on("close", () => { - writeStream.close(); - if (cancelled) { - resolve(false); - } else { - resolve(true); - } - }); - }); - }, - ); - - // False means the user canceled, although in practice it appears we - // would not get this far because VS Code already throws on cancelation. - if (!completed) { - this.output.warn("User aborted download"); - throw new Error("User aborted download"); - } - - this.output.info( - `Downloaded ${prettyBytes(written)} to`, - path.basename(tempFile), - ); - // Move the old binary to a backup location first, just in case. And, // on Linux at least, you cannot write onto a binary that is in use so // moving first works around that (delete would also work). @@ -389,7 +290,7 @@ export class Storage { } const params = new URLSearchParams({ title: `Failed to download binary on \`${cli.goos()}-${cli.goarch()}\``, - body: `Received status code \`${resp.status}\` when downloading the binary.`, + body: `Received status code \`${status}\` when downloading the binary.`, }); const uri = vscode.Uri.parse( `https://github.com/coder/vscode-coder/issues/new?` + @@ -402,6 +303,121 @@ export class Storage { } } + /** + * Download the source to the provided stream with a progress dialog. Return + * the status code or throw if the user aborts or there is an error. + */ + private async download( + client: AxiosInstance, + source: string, + writeStream: WriteStream, + headers?: AxiosRequestConfig["headers"], + ): Promise { + const baseUrl = client.defaults.baseURL; + + const controller = new AbortController(); + const resp = await client.get(source, { + signal: controller.signal, + baseURL: baseUrl, + responseType: "stream", + headers, + decompress: true, + // Ignore all errors so we can catch a 404! + validateStatus: () => true, + }); + this.output.info("Got status code", resp.status); + + if (resp.status === 200) { + const rawContentLength = resp.headers["content-length"]; + const contentLength = Number.parseInt(rawContentLength); + if (Number.isNaN(contentLength)) { + this.output.warn( + "Got invalid or missing content length", + rawContentLength, + ); + } else { + this.output.info("Got content length", prettyBytes(contentLength)); + } + + // Track how many bytes were written. + let written = 0; + + const completed = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Downloading ${baseUrl}`, + cancellable: true, + }, + async (progress, token) => { + const readStream = resp.data as IncomingMessage; + let cancelled = false; + token.onCancellationRequested(() => { + controller.abort(); + readStream.destroy(); + cancelled = true; + }); + + // Reverse proxies might not always send a content length. + const contentLengthPretty = Number.isNaN(contentLength) + ? "unknown" + : prettyBytes(contentLength); + + // Pipe data received from the request to the stream. + readStream.on("data", (buffer: Buffer) => { + writeStream.write(buffer, () => { + written += buffer.byteLength; + progress.report({ + message: `${prettyBytes(written)} / ${contentLengthPretty}`, + increment: Number.isNaN(contentLength) + ? undefined + : (buffer.byteLength / contentLength) * 100, + }); + }); + }); + + // Wait for the stream to end or error. + return new Promise((resolve, reject) => { + writeStream.on("error", (error) => { + readStream.destroy(); + reject( + new Error( + `Unable to download binary: ${errToStr(error, "no reason given")}`, + ), + ); + }); + readStream.on("error", (error) => { + writeStream.close(); + reject( + new Error( + `Unable to download binary: ${errToStr(error, "no reason given")}`, + ), + ); + }); + readStream.on("close", () => { + writeStream.close(); + if (cancelled) { + resolve(false); + } else { + resolve(true); + } + }); + }); + }, + ); + + // False means the user canceled, although in practice it appears we + // would not get this far because VS Code already throws on cancelation. + if (!completed) { + this.output.warn("User aborted download"); + throw new Error("Download aborted"); + } + + this.output.info(`Downloaded ${prettyBytes(written)}`); + } + + return resp.status; + } + /** * Return the directory for a deployment with the provided label to where its * binary is cached. From e9034820b2e192733dd634f308af9f376db3768a Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 23 Jul 2025 11:24:37 -0800 Subject: [PATCH 2/7] Get config once A tiny refactor since I will need to get a third config option. --- src/storage.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/storage.ts b/src/storage.ts index 0d249dd8..8b4c7386 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -123,13 +123,11 @@ export class Storage { * downloads being disabled. */ public async fetchBinary(restClient: Api, label: string): Promise { - const baseUrl = restClient.getAxiosInstance().defaults.baseURL; + const cfg = vscode.workspace.getConfiguration("coder"); // Settings can be undefined when set to their defaults (true in this case), // so explicitly check against false. - const enableDownloads = - vscode.workspace.getConfiguration().get("coder.enableDownloads") !== - false; + const enableDownloads = cfg.get("enableDownloads") !== false; this.output.info("Downloads are", enableDownloads ? "enabled" : "disabled"); // Get the build info to compare with the existing binary version, if any, @@ -189,9 +187,7 @@ export class Storage { // Figure out where to get the binary. const binName = cli.name(); - const configSource = vscode.workspace - .getConfiguration() - .get("coder.binarySource"); + const configSource = cfg.get("binarySource"); const binSource = configSource && String(configSource).trim().length > 0 ? String(configSource) From 860b1aae65716ac7b6fc8defcea276091e518b65 Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 23 Jul 2025 12:21:54 -0800 Subject: [PATCH 3/7] Add binary signature verification --- CHANGELOG.md | 12 +- fixtures/pgp/cli | 1 + fixtures/pgp/cli.invalid.asc | 1 + fixtures/pgp/cli.valid.asc | 16 +++ fixtures/pgp/private.pgp | 205 +++++++++++++++++++++++++++++++++++ fixtures/pgp/public.pgp | 97 +++++++++++++++++ package.json | 6 + pgp-public.key | 99 +++++++++++++++++ src/api-helper.ts | 5 +- src/cliManager.test.ts | 15 +++ src/cliManager.ts | 10 +- src/extension.ts | 1 + src/pgp.test.ts | 91 ++++++++++++++++ src/pgp.ts | 92 ++++++++++++++++ src/storage.ts | 142 +++++++++++++++++++++++- yarn.lock | 5 + 16 files changed, 789 insertions(+), 9 deletions(-) create mode 100644 fixtures/pgp/cli create mode 100644 fixtures/pgp/cli.invalid.asc create mode 100644 fixtures/pgp/cli.valid.asc create mode 100644 fixtures/pgp/private.pgp create mode 100644 fixtures/pgp/public.pgp create mode 100644 pgp-public.key create mode 100644 src/pgp.test.ts create mode 100644 src/pgp.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 80371d86..72584985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,20 @@ ## Unreleased +### Changed + +- Coder output panel enhancements: All log entries now include timestamps, and you + can filter messages by log level in the panel. + +### Added + - Update `/openDevContainer` to support all dev container features when hostPath and configFile are provided. - Add `coder.disableUpdateNotifications` setting to disable workspace template update notifications. -- Coder output panel enhancements: All log entries now include timestamps, and you - can filter messages by log level in the panel. +- Add binary signature verification. This can be disabled with + `coder.disableSignatureVerification` if you purposefully run a binary that is + not signed by Coder (for example a binary you built yourself). ## [v1.9.2](https://github.com/coder/vscode-coder/releases/tag/v1.9.2) 2025-06-25 diff --git a/fixtures/pgp/cli b/fixtures/pgp/cli new file mode 100644 index 00000000..dd7d9475 --- /dev/null +++ b/fixtures/pgp/cli @@ -0,0 +1 @@ +just a plain text file actually diff --git a/fixtures/pgp/cli.invalid.asc b/fixtures/pgp/cli.invalid.asc new file mode 100644 index 00000000..255f1fcd --- /dev/null +++ b/fixtures/pgp/cli.invalid.asc @@ -0,0 +1 @@ +this is not a valid signature diff --git a/fixtures/pgp/cli.valid.asc b/fixtures/pgp/cli.valid.asc new file mode 100644 index 00000000..5326e32f --- /dev/null +++ b/fixtures/pgp/cli.valid.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCAAdFiEElI8elUlV/nwf5io1K2wJvi5wa2UFAmiBVqsACgkQK2wJvi5w +a2XJ2A//bGHGzNcVSvB85NYd6ORVzr6RMSdGxezGU8WykXfQtd5LxqDi7f+SXxKU +AVC8UlPKvmLqWiNcm2Obd2HKtjb2ZKlJ6r8FhwjrBGCtqmdnVdM9B6gaobTZnF9N +8NqbzW9iyLCp1xzBfSp4nM/zcYD/04/0vWT12O6KSfaPfCpMKnpNM3ybnC6Ctfo/ +zczBZKt2M8dITYmXGmlZHNviHzxlFH9Mu8taarw87npBzvHcbnHPkBbNh5bQfEQn +pDQqvcS1cNn8We3yVqpwcr40I9gjhvi9XqYtxlZh+p5xEOWtWhj04Rf/KJNseULy +T76WI59BQcBfJYvkeexgIrT0WA/bv49ehwA+hRHtOCQ+QCYvOGe7WCVyFFwGfpIu +HPz2uq5Y1ZM9b/T59bSK/HPR1YVOBL7s7bS4H/l3caATXTw7GhlQcrlkuvHCv81n +O3bQy0+Ya3kVgckDO9ERT3X6z5to85s8qKHEzZzosTdTfFAWONwBZDCwPiYxbNCb +Q9xC9ik3FniN8/IEXjHKq/r3jJqMUOFI7bDczkIxqux75qg5DC6dp5tmFSXWowgK +0VeewR44+0r4tCgCYA/NW396iGL7ccABDmCaB98Z9HQRV8ds23SSk2YWGZhHB+nl +VYd79zVD/UlGWT1R5ctUWbH5EbvocT3wqYPhwsHYWIIGg4ba/lA= +=gs15 +-----END PGP SIGNATURE----- diff --git a/fixtures/pgp/private.pgp b/fixtures/pgp/private.pgp new file mode 100644 index 00000000..df11f488 --- /dev/null +++ b/fixtures/pgp/private.pgp @@ -0,0 +1,205 @@ +-----BEGIN PGP PRIVATE KEY BLOCK----- + +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 +TapMVt2/uSzfhwrpOTVslbN+GQ7L841ZEy8K +=YS3p +-----END PGP PRIVATE KEY BLOCK----- diff --git a/fixtures/pgp/public.pgp b/fixtures/pgp/public.pgp new file mode 100644 index 00000000..4a73a950 --- /dev/null +++ b/fixtures/pgp/public.pgp @@ -0,0 +1,97 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGiBUVoBEADp4uTJi/9LZOtibc/2L5VVziAighmczyY0H0dXpHgSmm5l2l1N +tK1W1iGvHpTOq5V1RPNnibDqXKFKf2eYe3bvCBVTJJN4+SzMt/KvKvS/uEpZ3GtA +S5ZBw/KzeduT6WxaYNMfe/W/2vP5k/xg+gt12RtTDYtZkl/+tIz9itHSCTL053lK +fLfY4VPFnLY2F1dOdGfqardKbPtvtk9QvH5YHjSjmOmrBd9ug2jxWJN95ud+3c62 +y8YULDYbuZFLbjqO1p7JpaakNF5PxarP6Ns0uRi8Vr8pc0vRqEsrtoC01nCd1kB+ +UdzRAi8yxE0VFH/YhGiFfwZokIVMJhicqucjNgbzUs1cD8vuTJi9Yo8iWMXVjQ9V +Uv8p55nN3mk/W+o+j+2z20OzYFHtE9eY3B301PJl0Ewge6QLqkRo/BkA2X+KHApV +B7ubU4CyKpb2IqfqwDQHycmbbHt9nJeqqWi7P3Aj/b9R9zZHi3LnLOkbMKls+tAy +iR3hRKgAzmXaMOZG3s0EWyntIXWd5IcViNCrC84RlCKeRkCKykakfRrtVzFEkJbR +FMcOr8mYawvczEtT8eEGt9COGPm8te8dmh6mZSNEK+NdTVGHIUvpfrm8fT31iGHs +Q/sDfr2WOTiH+GacGNlIDRH2ir6G8khMuHsTskWSEOsKAvcWqisx0xs45wARAQAB +tA50ZXN0QGNvZGVyLmNvbYkCTAQTAQoANhYhBBpzxYIIbpMIYkxNU0UIKbx42z96 +BQJogVFaAhsDBAsJCAcEFQoJCAUWAgMBAAIeBQIXgAAKCRBFCCm8eNs/etYxD/4y +MMQHKF0elO7K1hD4dnKYRESTCWpP99fyxkulnaaPTgu6dClMqZaGTMwF+aN2wiZ+ +Gs1M6KYPRPTVDbv2bsga2BQnMuEv2kuoQswcObfdEsXz3SirFEdH4N7FCeRT81vd +wM1AuUdUiphH5yfbICnHncDxifWm0lnfLWf+NHi8nKXqf6Uu5rlaruTahSmDd9bo ++3OTe7JJaIPPhW7xGZ3lotSWL9tspRVlZpbKCzlF2JREFKZSVvNFi85I77gMxfCF +manpWvTX5KB6d+kOv45XgP/Y87N1mm8rTW2yNmDCjSN+1mK0/XslDkNOYUgbYHMS +8deFBdETR6dduXYzlm0CI6LHGJJWPuXTpcS5yTpWj0byCI9AmwZshGM9qtSzuYz6 +GqE/9JMmo+bgqND7iC4oG9TFn2N8P4wRRoAd+gkJREptS6Bh4rJLfk1C4IikxKM4 +vCMmUEJ31jslyC2OYglGR6gW4p7PV8Stb8RB9oJoIWOE+WdxyrsU4HA1Erwa8CP4 +bvrwe6pfwpyzMfc2MG9EjrK2b/8Ch7wQbPMol+H66aokTDzC2QO0Me8uG82YzF5y +F6VV8mw5nKcBv2YHFj3ZsMMX72kKvhuFMhGdim+eNbIir7bDKv4ZCYFrt+wyJRAH +oIKvHyAcERsM1foo67/TqQAXQe+az/jO1IrhDaIU6bkCDQRogVFaARAAvnWefFSA +/K9YnwpQh83tvyUbUjhWDdDM8yXLRS0G27pGvmxkXV0SFwpDMAHPzFxWcvs+qNhv +oPkbKR/W/LKE6dRskYbdFzCuPe2LN9lfzLnJ9J+8ATMiR+9lX1XsU5ar9vWK33YL +VEc2GBxYEOxS8A6AhD4OnFqt3xZh30ZK+RtWi+3bGoWbaqM0f2OxUcR3Hno+vzWF +TJbO+PYYqUyzHKIa/uZrnpkmoI5hsK9ETsKaQVKZgbQq6x1uev77EqhGks/Og5VA +wEL5u9Toj0tbDM/mjSIF8jxvjPPG7x4T4v4e+d4EkcJ1bFZEQiF4RS6T8QPxryIv +QMFiztulIWXK/fWz1r/c3daiUsf0ND+Wc36TJ+8w/HpASGhSFwCVboRWSdWhY53c +nDdrN8PXUx0E4M1ZcuZyBIZXA4OB6sOzml/hsWK2dafz47lSLhrMe90yMvDkJ8bo +8XAaC49Ga2JKNaqRJRmLNhggnVIDB6bpcKJTHmJb+p3s/imEmNZQ6mgkCvJDD9g9 +JWQk8eaEvoY8bh5zx4M8qd+57nf7/czMHEUeQsJA/G3OQdG9/j36FQ19Ds6RlGaZ +PCBWtrm2/JXu9iDAeILTgX42o0sTTBTXvk9LekwOdq6edYi29dXl0RPIGkrrNeQK +uxMYYE3eYeGxi4iCtdpDbWJpqqqR3IINIGMAEQEAAYkCNgQYAQoAIBYhBBpzxYII +bpMIYkxNU0UIKbx42z96BQJogVFaAhsMAAoJEEUIKbx42z96ZIwP/1YmZeT8aT2Y +inPDHcD/eDf0rK8cRFt+nPR1hVPFJMo08TefKiXdY168+10/P2mOoTVMxD+UkTAJ +4p5yJ+5vvcmwsv8TKN5w7FbfKQxSfMV0/UaXn0nsSoTBOWk5UEH7OEay9Hkj9gUy +c12IAoTZnfZ15TqGDxcco3VnrP/oigXKFi/DbEpDfEEf7ikXu+ie6eMqk8rbr1SY +b/W7UeiNvruPv4DyGS0Bmvt4lXE9QQdg/R9OcURlrkhdaPMXWlXs9AYxW22qSELP +H2QSM9I33H4bjFr1WVclmr+9jkZt95IvbP9TcjDdjyDrBK3W5mCVKIw0UPYwL/mo +m6+/mhFbFNKV/6Kmk3gjyf6IvTB4lvMbOs8dnPysTXWdy2akV6cK0nR1AmuAHY4s +U7oO5nS2D8vZV+kCwfd+s4IMmHX5T6BBIqsEIzWueBs40bEHVNgrgLR/342svl0A +25RTPE33rW/XsIQKvr3LbNijc4GI/vaKdCDBBvBARFIJZMq0njUMKYfe1EDdeMJI +gnIeTJXT5Nq3KAnv1LGhwywitS3i0YleiyBx/lwEC3qK1aJZ4y0hzd04IBh035Dt +eblSZBKOoTQYqclH2stancllJ6DHn4dks/wix4DCRh2AKDTbSvT15pk1NLxMW+1w +Cz8VDFs1RNn7LbtuBmw15SukiobBJu/pmQINBGiBUbkBEADp3VE4fT3M/b6Z6q7x +R6sm/EHsjZ5eJg+AQCmNcPPbxn8DJJycOf8suYbZMLq+UbOehe1u0lFUSESheRFT +0hd3zyVgjSmV8o+oFEiplhmUg1xAztqajfvbOUnGVaoQxJ+R8vS7HJWfsYm34zUc +UJ2aOv8ceE9bZkC9WaI8718FyTp4iNcKrR3/qyz/62952Pc6999lHjM/7vDu+CYn +1aPrppH/Z29zzGdGJCvHB1aGs7SSKyMEXI9TOqjCk2BZhJVZzjqh0LwDu8UB4HIT +q8dOVBPMDauQ5F5KSZgyzZu4z32x7SVBKnXiXhe2v6hEBCiIqxJu4NNuiWRnAmSL +Pmtfkb8vaPI00Ma98gEqeWjxfw/NSvsXcMDClpf7jvd5jxB+PKp0evZZQIhhHSdo +VAWvY6Hz0Bh5Gx2IV+XNo2xuBaboFdP215snYPbKZXnYnJjQCg1pGdnDQUCjzRWK +3Gosnb0IgNqh40V6McyAirVdmumS8cgdUgnFHgV1Llpti5UKFfQox+hj5wuehRy5 +5nQCwostNut6AOk4t22ZtwZivwuYmdMAeSOGQ2D2hODYOKglCkERYGjVRkcbykDz +whID6yvDRfYN/HQ4F/Dcp8D3IwQ365aRIaDZmwyKNBPUquEYHYASUOG9QstBptLj +MoHge36yRK+VDw1z6m1+wnE+OQARAQABtA50ZXN0QGNvZGVyLmNvbYkCTAQTAQoA +NhYhBJSPHpVJVf58H+YqNStsCb4ucGtlBQJogVG5AhsDBAsJCAcEFQoJCAUWAgMB +AAIeBQIXgAAKCRArbAm+LnBrZZ3FD/9Iiupb7K+wmpcifrUzpk9/h8tnN0XwZ6Sj +ek7FlZlaULIz+CPN1Sk3q1ItTErEZspGoFRINw02nv32gXZoANA5Q7OQa5RjxXgH +J6LMIfPEiZxkvn7Aaf0EwUkbDzvO1UIAXyaWI3BEL3OrqniNVEfU+wx/dhzT9iv7 +JQOdNP1DmTbjbUkbbfsOMMbUitdeHY99itwW6hDDwYH7qN+YEHclqEcJdZ5WNXwA +oJI7OdtTW+oAxBZHeYziCiF6Omy5n0ctkoOBGX5EgFEBve/aWHVOtRKqk42rlB+f +5D+498YDAQcQKLPzUyiL4eZ1v2X34GOGgqCuncjQ7DKjPJc3pbZOlv15rJgomY64 +Vc1J4TObKj3UkJRukC9vV8W0+D6xt+gUfhvIUUnTnr4aGS8Uj5D78JRSiZ3seNU1 +yMo23dSAnKWXrgLAbNdnZ6LGa1ZQsku0c2F3eMpYCmksIJGTZsMJfkjhy22a8ImK +2a58cAYB4bBCpOxu+rbtR38nBsyOi1w7pwwGSULItFWRLBguIUml6/Dg4TnR7PGE +xdYPrJjv9mGnMf/RgwmVC2QiI/5UB0C8f3x886E3d4s1EqNwYXBZPP0ikjLC3oyF +MgkRKUts/b4TnqIMlJdnXTYqKBJKkCWHwkYldh8+6kzSUANS3wIsSA+roSFZ8tKJ +ZH2wNiSTRLkCDQRogVG5ARAAqIBj0y3Q/VnC1fvUmC1j1mssRx7ONz/YkOq0nyHb +JjU1A1RgDTbsfRZdbioJUBypJzAMIvDouscp8G8VthAaQWz/zO3vgH+xB0szGtzE +FcfABH/SEZ+faQnAwSjhhUQgyxUU6acksySyDD3WE7+Z5gOJTF7c0k9UrwVf9nhb +DA9J5kHJhJA28YrBTkXFsiTafj/wUuIFLvQ54E6ZzYQrOtIqtLDkbcVU+UnFGozD +88fY1zbumVZ9ar30NKErYi91fmUllhrpmdthMnPTd9jXUMj66v/1MnMNcQJqTApN +aURDxJHvoZI1wnS9V/xee5wuMNUWMmx25uVMNBi8as2IjdXyw/BMOK04DvhsSGIS +XIFm6PwSHP8skPJsbodGQ6SFmmLkb6Kuyh6HaTlrjr6jIyKgxirDfEQfsOgUaUyK +4JdxZGPGkHlPiVlTCjpPB0F1aJ2aQUwaSTzL3O3rOq75R4pME4gwOBIfrqMDMY9C +jhRtJHbkChklq9K4IyztnVFmWnG1xhKPrxBajqnPDIR0SCkujYzIbxVAggQlAzGS +RR+noKIvF5/2ZFDBSzh4ea9+CWenZxIp/heW7iyozrNpBoscmbxbIbyzUxlvvUno +JaCjXV5u7KAQ1h5C7O2hZML0Ek8uoqnVIHF4h3OkN5NqwNNmpN7rhSZ8CUTpmJuZ +QpEAEQEAAYkCNgQYAQoAIBYhBJSPHpVJVf58H+YqNStsCb4ucGtlBQJogVG5AhsM +AAoJECtsCb4ucGtlhMsP/ilFm14x8og6KFT864H9TlVmdxbJvUFDJX2KMLZeSTMo +MatYxAX/HEMlRwxb4xv/HgYYhvB4zXeUeaz+4J7NjYhDuVUVSN9zX0k94jRAxEle +QAETP0hzDxUEkdOdwQIG8PxlYv3Nx/u2MQqCEBFHZbGra2ZRMRcSdvS2Zf4JyCXA +zmG1sJXejXYWL8a7U/heW0uyjrSfAWmJtXYeRdxtWnO0rykVXbparE5buzESVaxV +mV3EQhugrCmTIpqM5FeJ8+jgi3mIPVPxNlgVNAdJ1yc79Ft7LvRLe9x2A0onJLE3 +SQhOe37f41g+lMuekbSypbt7abCpki9QS1iZCNAeH7uSZA+IaKmOFG4vCzyOQdf6 +lSgx7UpxlKk0qi/iczHXrEEC24yn+kObf0Nkkbs+5gmB+12m37xJnhmFoBV0hhNG +lDSN1J6ALCY7dMB9Gw8d3uX9nQaCAQdUi8YiWgaFgODJZNq6VcLICyZmrgGd2ia9 +x4GyTjyNUbbR46MYXk6kfCdLY/ZFBLOHq0y5FBDypP2ryf1ptR6jm1tLuszOD5rr +NyZs/5/ZWhb52Cllz7jrRoCqUwyNMCdwTtijRX6ZOvcDunnX9kVyIijRH1SHqo+y +5/XROBVkbUqIo5uHkc5MUkZg1oUNTapMVt2/uSzfhwrpOTVslbN+GQ7L841ZEy8K +=5/kG +-----END PGP PUBLIC KEY BLOCK----- diff --git a/package.json b/package.json index 7896243e..8a5f3062 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,11 @@ "markdownDescription": "Disable notifications when workspace template updates are available.", "type": "boolean", "default": false + }, + "coder.disableSignatureVerification": { + "markdownDescription": "Disable Coder CLI signature verification, which can be useful if you run an unsigned fork of the binary.", + "type": "boolean", + "default": false } } }, @@ -289,6 +294,7 @@ "jsonc-parser": "^3.3.1", "memfs": "^4.17.1", "node-forge": "^1.3.1", + "openpgp": "^6.2.0", "pretty-bytes": "^6.1.1", "proxy-agent": "^6.4.0", "semver": "^7.7.1", diff --git a/pgp-public.key b/pgp-public.key new file mode 100644 index 00000000..fb5c4c50 --- /dev/null +++ b/pgp-public.key @@ -0,0 +1,99 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=dLmT +-----END PGP PUBLIC KEY BLOCK----- \ No newline at end of file diff --git a/src/api-helper.ts b/src/api-helper.ts index d2a32644..ba287167 100644 --- a/src/api-helper.ts +++ b/src/api-helper.ts @@ -3,7 +3,10 @@ import { Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated"; import { ErrorEvent } from "eventsource"; import { z } from "zod"; -export function errToStr(error: unknown, def: string) { +export function errToStr( + error: unknown, + def: string = "No error message provided", +) { if (error instanceof Error && error.message) { return error.message; } else if (isApiError(error)) { diff --git a/src/cliManager.test.ts b/src/cliManager.test.ts index aa3eacd9..87540a61 100644 --- a/src/cliManager.test.ts +++ b/src/cliManager.test.ts @@ -106,12 +106,23 @@ describe("cliManager", () => { await fs.writeFile(path.join(binDir, "bin.temp-2"), "echo hello"); await fs.writeFile(path.join(binDir, "bin1"), "echo hello"); await fs.writeFile(path.join(binDir, "bin2"), "echo hello"); + await fs.writeFile(path.join(binDir, "bin.asc"), "echo hello"); + await fs.writeFile(path.join(binDir, "bin.old-1.asc"), "echo hello"); + await fs.writeFile(path.join(binDir, "bin.temp-2.asc"), "echo hello"); expect(await cli.rmOld(path.join(binDir, "bin1"))).toStrictEqual([ + { + fileName: "bin.asc", + error: undefined, + }, { fileName: "bin.old-1", error: undefined, }, + { + fileName: "bin.old-1.asc", + error: undefined, + }, { fileName: "bin.old-2", error: undefined, @@ -124,6 +135,10 @@ describe("cliManager", () => { fileName: "bin.temp-2", error: undefined, }, + { + fileName: "bin.temp-2.asc", + error: undefined, + }, ]); expect(await fs.readdir(path.join(tmp, "bins"))).toStrictEqual([ diff --git a/src/cliManager.ts b/src/cliManager.ts index 3088a829..60b63f92 100644 --- a/src/cliManager.ts +++ b/src/cliManager.ts @@ -78,8 +78,8 @@ export type RemovalResult = { fileName: string; error: unknown }; /** * Remove binaries in the same directory as the specified path that have a - * .old-* or .temp-* extension. Return a list of files and the errors trying to - * remove them, when applicable. + * .old-* or .temp-* extension along with signatures (files ending in .asc). + * Return a list of files and the errors trying to remove them, when applicable. */ export async function rmOld(binPath: string): Promise { const binDir = path.dirname(binPath); @@ -88,7 +88,11 @@ export async function rmOld(binPath: string): Promise { const results: RemovalResult[] = []; for (const file of files) { const fileName = path.basename(file); - if (fileName.includes(".old-") || fileName.includes(".temp-")) { + if ( + fileName.includes(".old-") || + fileName.includes(".temp-") || + fileName.endsWith(".asc") + ) { try { await fs.rm(path.join(binDir, file), { force: true }); results.push({ fileName, error: undefined }); diff --git a/src/extension.ts b/src/extension.ts index 96f110c5..18598df9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -49,6 +49,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const output = vscode.window.createOutputChannel("Coder", { log: true }); const storage = new Storage( + vscodeProposed, output, ctx.globalState, ctx.secrets, diff --git a/src/pgp.test.ts b/src/pgp.test.ts new file mode 100644 index 00000000..a05397f1 --- /dev/null +++ b/src/pgp.test.ts @@ -0,0 +1,91 @@ +import fs from "fs/promises"; +import * as openpgp from "openpgp"; +import path from "path"; +import { describe, expect, it } from "vitest"; +import * as pgp from "./pgp"; + +describe("pgp", () => { + // This contains two keys, like Coder's. + const publicKeysPath = path.join(__dirname, "../fixtures/pgp/public.pgp"); + // Just a text file, not an actual binary. + const cliPath = path.join(__dirname, "../fixtures/pgp/cli"); + const invalidSignaturePath = path.join( + __dirname, + "../fixtures/pgp/cli.invalid.asc", + ); + // This is signed with the second key, like Coder's. + const validSignaturePath = path.join( + __dirname, + "../fixtures/pgp/cli.valid.asc", + ); + + it("reads bundled public keys", async () => { + const keys = await pgp.readPublicKeys(); + expect(keys.length).toBe(2); + expect(keys[0].getKeyID().toHex()).toBe("8bced87dbbb8644b"); + expect(keys[1].getKeyID().toHex()).toBe("6a5a671b5e40a3b9"); + }); + + it("cannot read non-existent signature", async () => { + const armoredKeys = await fs.readFile(publicKeysPath, "utf8"); + const publicKeys = await openpgp.readKeys({ armoredKeys }); + const result = await pgp.verifySignature( + publicKeys, + cliPath, + path.join(__dirname, "does-not-exist"), + ); + expect((result as pgp.VerificationError).code).toBe( + pgp.VerificationErrorCode.Read, + ); + }); + + it("cannot read invalid signature", async () => { + const armoredKeys = await fs.readFile(publicKeysPath, "utf8"); + const publicKeys = await openpgp.readKeys({ armoredKeys }); + const result = await pgp.verifySignature( + publicKeys, + cliPath, + invalidSignaturePath, + ); + expect((result as pgp.VerificationError).code).toBe( + pgp.VerificationErrorCode.Read, + ); + }); + + it("cannot read file", async () => { + const armoredKeys = await fs.readFile(publicKeysPath, "utf8"); + const publicKeys = await openpgp.readKeys({ armoredKeys }); + const result = await pgp.verifySignature( + publicKeys, + path.join(__dirname, "does-not-exist"), + validSignaturePath, + ); + expect((result as pgp.VerificationError).code).toBe( + pgp.VerificationErrorCode.Read, + ); + }); + + it("mismatched signature", async () => { + const armoredKeys = await fs.readFile(publicKeysPath, "utf8"); + const publicKeys = await openpgp.readKeys({ armoredKeys }); + const result = await pgp.verifySignature( + publicKeys, + __filename, + validSignaturePath, + ); + expect((result as pgp.VerificationError).code).toBe( + pgp.VerificationErrorCode.Invalid, + ); + }); + + it("verifies signature", async () => { + const armoredKeys = await fs.readFile(publicKeysPath, "utf8"); + const publicKeys = await openpgp.readKeys({ armoredKeys }); + const result = await pgp.verifySignature( + publicKeys, + cliPath, + validSignaturePath, + ); + expect(result).toBe(true); + }); +}); diff --git a/src/pgp.ts b/src/pgp.ts new file mode 100644 index 00000000..835855ae --- /dev/null +++ b/src/pgp.ts @@ -0,0 +1,92 @@ +import { createReadStream, promises as fs } from "fs"; +import * as openpgp from "openpgp"; +import * as path from "path"; +import { Readable } from "stream"; +import * as vscode from "vscode"; +import { errToStr } from "./api-helper"; + +export type Key = openpgp.Key; + +export enum VerificationErrorCode { + /* The signature does not match. */ + Invalid = "Invalid", + /* Failed to read the signature or the file to verify. */ + Read = "Read", +} + +export class VerificationError extends Error { + constructor( + public readonly code: VerificationErrorCode, + message: string, + ) { + super(message); + } + + summary(): string { + switch (this.code) { + case VerificationErrorCode.Invalid: + return "Signature does not match"; + default: + return "Failed to read signature"; + } + } +} + +/** + * Return the public keys bundled with the plugin. + */ +export async function readPublicKeys( + logger?: vscode.LogOutputChannel, +): Promise { + const keyFile = path.join(__dirname, "../pgp-public.key"); + logger?.info("Reading public key", keyFile); + const armoredKeys = await fs.readFile(keyFile, "utf8"); + return openpgp.readKeys({ armoredKeys }); +} + +/** + * Given public keys, a path to a file to verify, and a path to a detached + * signature, verify the file's signature. Return true if valid, otherwise + * return VerificationError. + */ +export async function verifySignature( + publicKeys: openpgp.Key[], + cliPath: string, + signaturePath: string, + logger?: vscode.LogOutputChannel, +): Promise { + try { + logger?.info("Reading signature", signaturePath); + const armoredSignature = await fs.readFile(signaturePath, "utf8"); + const signature = await openpgp.readSignature({ armoredSignature }); + + logger?.info("Verifying signature of", cliPath); + const message = await openpgp.createMessage({ + // openpgpjs only accepts web readable streams. + binary: Readable.toWeb(createReadStream(cliPath)), + }); + const verificationResult = await openpgp.verify({ + message, + signature, + verificationKeys: publicKeys, + }); + for await (const _ of verificationResult.data) { + // The docs indicate this data must be consumed; it triggers the + // verification of the data. + } + try { + const { verified } = verificationResult.signatures[0]; + await verified; // Throws on invalid signature. + logger?.info("Binary signature matches"); + } catch (e) { + const error = `Unable to verify the authenticity of the binary: ${errToStr(e)}. The binary may have been tampered with.`; + logger?.warn(error); + return new VerificationError(VerificationErrorCode.Invalid, error); + } + } catch (e) { + const error = `Failed to read signature or binary: ${errToStr(e)}.`; + logger?.warn(error); + return new VerificationError(VerificationErrorCode.Read, error); + } + return true; +} diff --git a/src/storage.ts b/src/storage.ts index 8b4c7386..c9169ed1 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,6 +1,9 @@ -import type { AxiosInstance, AxiosRequestConfig } from "axios"; +import globalAxios, { + type AxiosInstance, + type AxiosRequestConfig, +} from "axios"; import { Api } from "coder/site/src/api/api"; -import { createWriteStream } from "fs"; +import { createWriteStream, type WriteStream } from "fs"; import fs from "fs/promises"; import { IncomingMessage } from "http"; import path from "path"; @@ -9,12 +12,14 @@ import * as vscode from "vscode"; import { errToStr } from "./api-helper"; import * as cli from "./cliManager"; import { getHeaderCommand, getHeaders } from "./headers"; +import * as pgp from "./pgp"; // Maximium number of recent URLs to store. const MAX_URLS = 10; export class Storage { constructor( + private readonly vscodeProposed: typeof vscode, public readonly output: vscode.LogOutputChannel, private readonly memento: vscode.Memento, private readonly secrets: vscode.SecretStorage, @@ -175,7 +180,7 @@ export class Storage { throw new Error("Unable to download CLI because downloads are disabled"); } - // Remove any left-over old or temporary binaries. + // Remove any left-over old or temporary binaries and signatures. const removed = await cli.rmOld(binPath); removed.forEach(({ fileName, error }) => { if (error) { @@ -215,6 +220,22 @@ export class Storage { switch (status) { case 200: { + if (cfg.get("disableSignatureVerification")) { + this.output.info( + "Skipping binary signature verification due to settings", + ); + } else { + await this.verifyBinarySignatures(client, tempFile, [ + // A signature placed at the same level as the binary. It must be + // named exactly the same with an appended `.asc` (such as + // coder-windows-amd64.exe.asc or coder-linux-amd64.asc). + binSource + ".asc", + // The releases.coder.com bucket does not include the leading "v". + // The signature name follows the same rule as above. + `https://releases.coder.com/coder-cli/${buildInfo.version.replace(/^v/, "")}/${binName}.asc`, + ]); + } + // Move the old binary to a backup location first, just in case. And, // on Linux at least, you cannot write onto a binary that is in use so // moving first works around that (delete would also work). @@ -414,6 +435,121 @@ export class Storage { return resp.status; } + /** + * Download detached signatures one at a time and use them to verify the + * binary. The first signature is always downloaded, but the next signatures + * are only tried if the previous ones did not exist and the user indicates + * they want to try the next source. + * + * If the first successfully downloaded signature is valid or it is invalid + * and the user indicates to use the binary anyway, return, otherwise throw. + * + * If no signatures could be downloaded, return if the user indicates to use + * the binary anyway, otherwise throw. + */ + private async verifyBinarySignatures( + client: AxiosInstance, + cliPath: string, + sources: string[], + ): Promise { + const publicKeys = await pgp.readPublicKeys(this.output); + for (let i = 0; i < sources.length; ++i) { + const source = sources[i]; + // For the primary source we use the common client, but for the rest we do + // not to avoid sending user-provided headers to external URLs. + if (i === 1) { + client = globalAxios.create(); + } + const status = await this.verifyBinarySignature( + client, + cliPath, + publicKeys, + source, + ); + if (status === 200) { + return; + } + // If we failed to download, try the next source. + let nextPrompt = ""; + const options: string[] = []; + const nextSource = sources[i + 1]; + if (nextSource) { + nextPrompt = ` Would you like to download the signature from ${nextSource}?`; + options.push("Download signature"); + } + options.push("Run without verification"); + const action = await this.vscodeProposed.window.showWarningMessage( + status === 404 ? "Signature not found" : "Failed to download signature", + { + useCustom: true, + modal: true, + detail: + status === 404 + ? `No binary signature was found at ${source}.${nextPrompt}` + : `Received ${status} trying to download binary signature from ${source}.${nextPrompt}`, + }, + ...options, + ); + switch (action) { + case "Download signature": { + continue; + } + case "Run without verification": + this.output.info(`Signature download from ${nextSource} declined`); + this.output.info("Binary will be ran anyway at user request"); + return; + default: + this.output.info(`Signature download from ${nextSource} declined`); + this.output.info("Binary was rejected at user request"); + throw new Error("Signature download aborted"); + } + } + // Reaching here would be a developer error. + throw new Error("Unable to download any signatures"); + } + + /** + * Download a detached signature and if successful (200 status code) use it to + * verify the binary. Throw if the binary signature is invalid and the user + * declined to run the binary, otherwise return the status code. + */ + private async verifyBinarySignature( + client: AxiosInstance, + cliPath: string, + publicKeys: pgp.Key[], + source: string, + ): Promise { + this.output.info("Downloading signature from", source); + const signaturePath = path.join(cliPath + ".asc"); + const writeStream = createWriteStream(signaturePath); + const status = await this.download(client, source, writeStream); + if (status === 200) { + const result = await pgp.verifySignature( + publicKeys, + cliPath, + signaturePath, + this.output, + ); + if (result !== true) { + const action = await this.vscodeProposed.window.showWarningMessage( + result.summary(), + { + useCustom: true, + modal: true, + detail: `${result.message} Would you like to accept this risk and run the binary anyway?`, + }, + "Run anyway", + ); + if (!action) { + this.output.info("Binary was rejected at user request"); + throw new Error("Signature verification aborted"); + } + this.output.info("Binary will be ran anyway at user request"); + } + } + return status; + } + /** * Return the directory for a deployment with the provided label to where its * binary is cached. diff --git a/yarn.lock b/yarn.lock index 2f863292..14463ff7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4771,6 +4771,11 @@ onetime@^7.0.0: dependencies: mimic-function "^5.0.0" +openpgp@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/openpgp/-/openpgp-6.2.0.tgz#f9ce7b4fa298c9d1c4c51f8d1bd0d6cb00372144" + integrity sha512-zKbgazxMeGrTqUEWicKufbdcjv2E0om3YVxw+I3hRykp8ODp+yQOJIDqIr1UXJjP8vR2fky3bNQwYoQXyFkYMA== + optionator@^0.8.3: version "0.8.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" From df064f95e7a4444f7f32483532838d1e8180095a Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 24 Jul 2025 12:05:12 -0800 Subject: [PATCH 4/7] Omit fixtures when packaging They are not needed, and the packaging step will error that it looks like you are trying to package secrets due to the test key fixtures. --- .vscodeignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.vscodeignore b/.vscodeignore index a51e2934..fe6dbade 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -13,3 +13,4 @@ node_modules/** **/*.map **/*.ts *.gif +fixtures/** From 46ee3f6ac3fa29acc6ddb81a808eab7b6f496521 Mon Sep 17 00:00:00 2001 From: Asher Date: Mon, 28 Jul 2025 11:10:00 -0800 Subject: [PATCH 5/7] Add trailing newline to public key --- pgp-public.key | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pgp-public.key b/pgp-public.key index fb5c4c50..d22c4911 100644 --- a/pgp-public.key +++ b/pgp-public.key @@ -96,4 +96,4 @@ k4F84AxTIjGfLJeXigyP6xIPQbvJy+8iLRaCpj+v/EPwAedbRV+u0JFeqqikca70 aGN86JBOmwpU87sfFxLI7HdI02DkvlxYYK3vYlA6zEyWaeLZ3VNr6tHcQmOnFe8Q 26gcS0AQcxQZrcWTCZ8DJYF+RnXjSVRmHV/3YDts4JyMKcD6QX8s/3aaldk= =dLmT ------END PGP PUBLIC KEY BLOCK----- \ No newline at end of file +-----END PGP PUBLIC KEY BLOCK----- From d6d1e7c906513d921ba8a4df0cdd3d23b271ff63 Mon Sep 17 00:00:00 2001 From: Asher Date: Mon, 28 Jul 2025 11:37:03 -0800 Subject: [PATCH 6/7] be me; in rome --- src/pgp.test.ts | 59 ++++++++++++++++++------------------------------- src/pgp.ts | 11 +++++---- src/storage.ts | 23 +++++++++++-------- 3 files changed, 40 insertions(+), 53 deletions(-) diff --git a/src/pgp.test.ts b/src/pgp.test.ts index a05397f1..6eeff95b 100644 --- a/src/pgp.test.ts +++ b/src/pgp.test.ts @@ -29,63 +29,46 @@ describe("pgp", () => { it("cannot read non-existent signature", async () => { const armoredKeys = await fs.readFile(publicKeysPath, "utf8"); const publicKeys = await openpgp.readKeys({ armoredKeys }); - const result = await pgp.verifySignature( - publicKeys, - cliPath, - path.join(__dirname, "does-not-exist"), - ); - expect((result as pgp.VerificationError).code).toBe( - pgp.VerificationErrorCode.Read, - ); + await expect( + pgp.verifySignature( + publicKeys, + cliPath, + path.join(__dirname, "does-not-exist"), + ), + ).rejects.toThrow("Failed to read"); }); it("cannot read invalid signature", async () => { const armoredKeys = await fs.readFile(publicKeysPath, "utf8"); const publicKeys = await openpgp.readKeys({ armoredKeys }); - const result = await pgp.verifySignature( - publicKeys, - cliPath, - invalidSignaturePath, - ); - expect((result as pgp.VerificationError).code).toBe( - pgp.VerificationErrorCode.Read, - ); + await expect( + pgp.verifySignature(publicKeys, cliPath, invalidSignaturePath), + ).rejects.toThrow("Failed to read"); }); it("cannot read file", async () => { const armoredKeys = await fs.readFile(publicKeysPath, "utf8"); const publicKeys = await openpgp.readKeys({ armoredKeys }); - const result = await pgp.verifySignature( - publicKeys, - path.join(__dirname, "does-not-exist"), - validSignaturePath, - ); - expect((result as pgp.VerificationError).code).toBe( - pgp.VerificationErrorCode.Read, - ); + await expect( + pgp.verifySignature( + publicKeys, + path.join(__dirname, "does-not-exist"), + validSignaturePath, + ), + ).rejects.toThrow("Failed to read"); }); it("mismatched signature", async () => { const armoredKeys = await fs.readFile(publicKeysPath, "utf8"); const publicKeys = await openpgp.readKeys({ armoredKeys }); - const result = await pgp.verifySignature( - publicKeys, - __filename, - validSignaturePath, - ); - expect((result as pgp.VerificationError).code).toBe( - pgp.VerificationErrorCode.Invalid, - ); + await expect( + pgp.verifySignature(publicKeys, __filename, validSignaturePath), + ).rejects.toThrow("Unable to verify"); }); it("verifies signature", async () => { const armoredKeys = await fs.readFile(publicKeysPath, "utf8"); const publicKeys = await openpgp.readKeys({ armoredKeys }); - const result = await pgp.verifySignature( - publicKeys, - cliPath, - validSignaturePath, - ); - expect(result).toBe(true); + await pgp.verifySignature(publicKeys, cliPath, validSignaturePath); }); }); diff --git a/src/pgp.ts b/src/pgp.ts index 835855ae..ff1119c1 100644 --- a/src/pgp.ts +++ b/src/pgp.ts @@ -46,15 +46,15 @@ export async function readPublicKeys( /** * Given public keys, a path to a file to verify, and a path to a detached - * signature, verify the file's signature. Return true if valid, otherwise - * return VerificationError. + * signature, verify the file's signature. Throw VerificationError if invalid + * or unable to validate. */ export async function verifySignature( publicKeys: openpgp.Key[], cliPath: string, signaturePath: string, logger?: vscode.LogOutputChannel, -): Promise { +): Promise { try { logger?.info("Reading signature", signaturePath); const armoredSignature = await fs.readFile(signaturePath, "utf8"); @@ -81,12 +81,11 @@ export async function verifySignature( } catch (e) { const error = `Unable to verify the authenticity of the binary: ${errToStr(e)}. The binary may have been tampered with.`; logger?.warn(error); - return new VerificationError(VerificationErrorCode.Invalid, error); + throw new VerificationError(VerificationErrorCode.Invalid, error); } } catch (e) { const error = `Failed to read signature or binary: ${errToStr(e)}.`; logger?.warn(error); - return new VerificationError(VerificationErrorCode.Read, error); + throw new VerificationError(VerificationErrorCode.Read, error); } - return true; } diff --git a/src/storage.ts b/src/storage.ts index c9169ed1..bbdb508c 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -524,19 +524,24 @@ export class Storage { const writeStream = createWriteStream(signaturePath); const status = await this.download(client, source, writeStream); if (status === 200) { - const result = await pgp.verifySignature( - publicKeys, - cliPath, - signaturePath, - this.output, - ); - if (result !== true) { + try { + await pgp.verifySignature( + publicKeys, + cliPath, + signaturePath, + this.output, + ); + } catch (error) { const action = await this.vscodeProposed.window.showWarningMessage( - result.summary(), + // VerificationError should be the only thing that throws, but + // unfortunately caught errors are always type unknown. + error instanceof pgp.VerificationError + ? error.summary() + : "Failed to verify signature", { useCustom: true, modal: true, - detail: `${result.message} Would you like to accept this risk and run the binary anyway?`, + detail: `${errToStr(error)} Would you like to accept this risk and run the binary anyway?`, }, "Run anyway", ); From c4f3d0a956a120af5e5d4749d4b634edad06ee7a Mon Sep 17 00:00:00 2001 From: Asher Date: Mon, 28 Jul 2025 11:39:56 -0800 Subject: [PATCH 7/7] No need for default --- src/pgp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pgp.ts b/src/pgp.ts index ff1119c1..2b6043f2 100644 --- a/src/pgp.ts +++ b/src/pgp.ts @@ -26,7 +26,7 @@ export class VerificationError extends Error { switch (this.code) { case VerificationErrorCode.Invalid: return "Signature does not match"; - default: + case VerificationErrorCode.Read: return "Failed to read signature"; } }