From 11245b6650b48059a580a6acddbe538af58e7b8a Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 29 Jul 2025 23:55:58 +0300 Subject: [PATCH 1/7] impl: add new configurable option to disable CLI signature verification These options are configurable from the Settings page there is no available shortcut on the main plugin page to discourage the quick disable of CLI verification --- .../gateway/CoderSettingsConfigurable.kt | 48 ++++++++++++------- .../coder/gateway/settings/CoderSettings.kt | 13 ++++- .../messages/CoderGatewayBundle.properties | 4 +- 3 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt b/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt index 2032dc69..64a140b4 100644 --- a/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt +++ b/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt @@ -8,13 +8,17 @@ import com.intellij.openapi.components.service import com.intellij.openapi.options.BoundConfigurable import com.intellij.openapi.ui.DialogPanel import com.intellij.openapi.ui.ValidationInfo +import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBTextField import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.Cell import com.intellij.ui.dsl.builder.RowLayout import com.intellij.ui.dsl.builder.bindSelected import com.intellij.ui.dsl.builder.bindText import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.builder.selected import com.intellij.ui.layout.ValidationInfoBuilder +import com.intellij.ui.layout.not import java.net.URL import java.nio.file.Path @@ -60,22 +64,27 @@ class CoderSettingsConfigurable : BoundConfigurable("Coder") { .bindText(state::binaryDirectory) .comment(CoderGatewayBundle.message("gateway.connector.settings.binary-destination.comment")) }.layout(RowLayout.PARENT_GRID) - row { - cell() // For alignment. - checkBox(CoderGatewayBundle.message("gateway.connector.settings.enable-binary-directory-fallback.title")) - .bindSelected(state::enableBinaryDirectoryFallback) - .comment( - CoderGatewayBundle.message("gateway.connector.settings.enable-binary-directory-fallback.comment"), - ) - }.layout(RowLayout.PARENT_GRID) - row { - cell() // For alignment. - checkBox(CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.title")) - .bindSelected(state::fallbackOnCoderForSignatures) - .comment( - CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.comment"), - ) - }.layout(RowLayout.PARENT_GRID) + group { + lateinit var signatureVerificationCheckBox: Cell + row { + cell() // For alignment. + signatureVerificationCheckBox = + checkBox(CoderGatewayBundle.message("gateway.connector.settings.disable-signature-validation.title")) + .bindSelected(state::disableSignatureVerification) + .comment( + CoderGatewayBundle.message("gateway.connector.settings.disable-signature-validation.comment"), + ) + }.layout(RowLayout.PARENT_GRID) + row { + cell() // For alignment. + checkBox(CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.title")) + .bindSelected(state::fallbackOnCoderForSignatures) + .comment( + CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.comment"), + ) + }.visibleIf(signatureVerificationCheckBox.selected.not()) + .layout(RowLayout.PARENT_GRID) + } row(CoderGatewayBundle.message("gateway.connector.settings.header-command.title")) { textField().resizableColumn().align(AlignX.FILL) .bindText(state::headerCommand) @@ -122,7 +131,10 @@ class CoderSettingsConfigurable : BoundConfigurable("Coder") { textArea().resizableColumn().align(AlignX.FILL) .bindText(state::sshConfigOptions) .comment( - CoderGatewayBundle.message("gateway.connector.settings.ssh-config-options.comment", CODER_SSH_CONFIG_OPTIONS), + CoderGatewayBundle.message( + "gateway.connector.settings.ssh-config-options.comment", + CODER_SSH_CONFIG_OPTIONS + ), ) }.layout(RowLayout.PARENT_GRID) row(CoderGatewayBundle.message("gateway.connector.settings.setup-command.title")) { @@ -162,7 +174,7 @@ class CoderSettingsConfigurable : BoundConfigurable("Coder") { .bindText(state::defaultIde) .comment( "The default IDE version to display in the IDE selection dropdown. " + - "Example format: CL 2023.3.6 233.15619.8", + "Example format: CL 2023.3.6 233.15619.8", ) } row(CoderGatewayBundle.message("gateway.connector.settings.check-ide-updates.heading")) { diff --git a/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt b/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt index 31d64d9c..b5fbb76b 100644 --- a/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt +++ b/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt @@ -65,7 +65,12 @@ open class CoderSettingsState( open var enableBinaryDirectoryFallback: Boolean = false, /** - * Controls whether we fall back release.coder.com + * Controls whether we verify the cli signature + */ + open var disableSignatureVerification: Boolean = false, + + /** + * Controls whether we fall back release.coder.com if signature validation is enabled */ open var fallbackOnCoderForSignatures: Boolean = false, @@ -160,6 +165,12 @@ open class CoderSettings( val enableBinaryDirectoryFallback: Boolean get() = state.enableBinaryDirectoryFallback + /** + * Controls whether we verify the cli signature + */ + val disableSignatureVerification: Boolean + get() = state.disableSignatureVerification + /** * Controls whether we fall back release.coder.com */ diff --git a/src/main/resources/messages/CoderGatewayBundle.properties b/src/main/resources/messages/CoderGatewayBundle.properties index 3364e6f3..7420b576 100644 --- a/src/main/resources/messages/CoderGatewayBundle.properties +++ b/src/main/resources/messages/CoderGatewayBundle.properties @@ -75,10 +75,10 @@ gateway.connector.settings.enable-binary-directory-fallback.title=Fall back to d gateway.connector.settings.enable-binary-directory-fallback.comment=Checking this \ box will allow the plugin to fall back to the data directory when the CLI \ directory is not writable. - +gateway.connector.settings.disable-signature-validation.title=Disable Coder CLI signature verification +gateway.connector.settings.disable-signature-validation.comment=Useful if you run an unsigned fork for the binary gateway.connector.settings.fallback-on-coder-for-signatures.title=Fall back on releases.coder.com for signatures gateway.connector.settings.fallback-on-coder-for-signatures.comment=Verify binary signature using releases.coder.com when CLI signatures are not available from the deployment - gateway.connector.settings.header-command.title=Header command gateway.connector.settings.header-command.comment=An external command that \ outputs additional HTTP headers added to all requests. The command must \ From 9e8e0073bac65f394f047067ffaa7cd61ddb11c6 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 30 Jul 2025 00:04:07 +0300 Subject: [PATCH 2/7] impl: hide configurable fallback if signature verification is disabled The main plugin screen has a quick shortcut for setting whether the user wants to fallback on releases.coder.com for signatures if they are not provided by the main deployment. This checkbox should not be visible if the user wants to disable signature verification altogether. --- .../com/coder/gateway/views/steps/CoderWorkspacesStepView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt index 51a7df4b..31304d63 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt @@ -306,7 +306,7 @@ class CoderWorkspacesStepView : CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.comment"), ) - }.layout(RowLayout.PARENT_GRID) + }.visible(state.disableSignatureVerification.not()).layout(RowLayout.PARENT_GRID) row { scrollCell( toolbar.createPanel().apply { From 0015879b4da6e9ac88bb0298d95148a67e79d9f4 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 30 Jul 2025 00:12:13 +0300 Subject: [PATCH 3/7] impl: skip signature validation Signature validation is skipped if the user configured the `disableSignatureVerification` to true. --- src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt index c916450e..e06b8702 100644 --- a/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt @@ -174,6 +174,11 @@ class CoderCLIManager( else -> result as DownloadResult.Downloaded } } + if (settings.disableSignatureVerification) { + downloader.commit() + logger.info("Skipping over CLI signature verification, it is disabled by the user") + return true + } var signatureResult = withContext(Dispatchers.IO) { downloader.downloadSignature(showTextProgress) From 7c4ac767c03242592b01695970a6ddb9e8d73279 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 30 Jul 2025 00:43:05 +0300 Subject: [PATCH 4/7] chore: update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b43a9f4f..3c25cd70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ## Unreleased +### Added + +- support for skipping CLI signature verification + ## 2.22.0 - 2025-07-25 ### Added From 9a8fee67a62aa2377a34e1b48367c4d02219f6ba Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 30 Jul 2025 00:43:53 +0300 Subject: [PATCH 5/7] chore: next version is 2.22.1 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index b3085324..bcc3a36b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ pluginGroup=com.coder.gateway artifactName=coder-gateway pluginName=Coder # SemVer format -> https://semver.org -pluginVersion=2.22.0 +pluginVersion=2.22.1 # See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html # for insight into build numbers and IntelliJ Platform versions. pluginSinceBuild=243.26574 From 11b9253a023ccca1a634db690a37bfbf53dbcfa0 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 30 Jul 2025 00:54:43 +0300 Subject: [PATCH 6/7] doc: developer facing documentation for CLI signature verification --- CONTRIBUTING.md | 64 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f79e3d82..d88e8d1e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,6 +16,70 @@ There are three ways to get into a workspace: Currently the first two will configure SSH but the third does not yet. +## GPG Signature Verification + +The Coder Gateway plugin starting with version *2.22.0* implements a comprehensive GPG signature verification system to +ensure the authenticity and integrity of downloaded Coder CLI binaries. This security feature helps protect users from +running potentially malicious or tampered binaries. + +### How It Works + +1. **Binary Download**: When connecting to a Coder deployment, the plugin downloads the appropriate Coder CLI binary for + the user's operating system and architecture from the deployment's `/bin/` endpoint. + +2. **Signature Download**: After downloading the binary, the plugin attempts to download the corresponding `.asc` + signature file from the same location. The signature file is named according to the binary (e.g., + `coder-linux-amd64.asc` for `coder-linux-amd64`). + +3. **Fallback Signature Sources**: If the signature is not available from the deployment, the plugin can optionally fall + back to downloading signatures from `releases.coder.com`. This is controlled by the `fallbackOnCoderForSignatures` + setting. + +4. **GPG Verification**: The plugin uses the BouncyCastle library shipped with Gateway app to verify the detached GPG + signature against the downloaded binary using Coder's trusted public key. + +5. **User Interaction**: If signature verification fails or signatures are unavailable, the plugin presents security + warnings + to users, allowing them to accept the risk and continue or abort the operation. + +### Verification Process + +The verification process involves several components: + +- **`GPGVerifier`**: Handles the core GPG signature verification logic using BouncyCastle +- **`VerificationResult`**: Represents the outcome of verification (Valid, Invalid, Failed, SignatureNotFound) +- **`CoderDownloadService`**: Manages downloading both binaries and their signatures +- **`CoderCLIManager`**: Orchestrates the download and verification workflow + +### Configuration Options + +Users can control signature verification behavior through plugin settings: + +- **`disableSignatureVerification`**: When enabled, skips all signature verification. This is useful for clients running + custom CLI builds, or + customers with old deployment versions that don't have a signature published on `releases.coder.com`. +- **`fallbackOnCoderForSignatures`**: When enabled, allows downloading signatures from `releases.coder.com` if not + available from the deployment + +### Security Considerations + +- The plugin embeds Coder's trusted public key in the plugin resources +- Verification uses detached signatures, which are more secure than attached signatures +- Users are warned about security risks when verification fails +- The system gracefully handles cases where signatures are unavailable +- All verification failures are logged for debugging purposes + +### Error Handling + +The system handles various failure scenarios: + +- **Missing signatures**: Prompts user to accept risk or abort +- **Invalid signatures**: Warns user about potential tampering and prompts user to accept risk or abort +- **Verification failures**: Prompts user to accept risk or abort + +This signature verification system ensures that users can trust the Coder CLI binaries they download through the plugin, +protecting against supply chain attacks and ensuring binary integrity. + ## Development To manually install a local build: From 2fbd236451be51f99b962d2f8225e61de673122c Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 30 Jul 2025 22:57:32 +0300 Subject: [PATCH 7/7] chore: fix UTs --- .../coder/gateway/settings/CoderSettings.kt | 4 +- .../coder/gateway/cli/CoderCLIManagerTest.kt | 2 +- .../coder/gateway/sdk/CoderRestClientTest.kt | 58 ++++++++++++------- .../gateway/settings/CoderSettingsTest.kt | 4 +- 4 files changed, 42 insertions(+), 26 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt b/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt index b5fbb76b..aa517746 100644 --- a/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt +++ b/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt @@ -114,7 +114,7 @@ open class CoderSettingsState( // Default version of IDE to display in IDE selection dropdown open var defaultIde: String = "", // Whether to check for IDE updates. - open var checkIDEUpdates: Boolean = true, + open var checkIDEUpdates: Boolean = true ) /** @@ -142,7 +142,7 @@ open class CoderSettings( // Overrides the default environment (for tests). private val env: Environment = Environment(), // Overrides the default binary name (for tests). - private val binaryName: String? = null, + private val binaryName: String? = null ) { val tls = CoderTLSSettings(state) diff --git a/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt index f0d82769..d83690b7 100644 --- a/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/gateway/cli/CoderCLIManagerTest.kt @@ -124,7 +124,7 @@ internal class CoderCLIManagerTest { CoderSettings( CoderSettingsState( dataDirectory = tmpdir.resolve("cli-data-dir").toString(), - binaryDirectory = tmpdir.resolve("cli-bin-dir").toString(), + binaryDirectory = tmpdir.resolve("cli-bin-dir").toString() ), ) val url = URL("http://localhost") diff --git a/src/test/kotlin/com/coder/gateway/sdk/CoderRestClientTest.kt b/src/test/kotlin/com/coder/gateway/sdk/CoderRestClientTest.kt index 877408f5..4af973a4 100644 --- a/src/test/kotlin/com/coder/gateway/sdk/CoderRestClientTest.kt +++ b/src/test/kotlin/com/coder/gateway/sdk/CoderRestClientTest.kt @@ -229,31 +229,44 @@ class CoderRestClientTest { // Nothing, so no resources. emptyList(), // One workspace with an agent, but no resources. - listOf(TestWorkspace(DataGen.workspace("ws1", agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a")))), + listOf( + TestWorkspace( + DataGen.workspace( + "ws1", + agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a") + ) + ) + ), // One workspace with an agent and resources that do not match the agent. listOf( TestWorkspace( - workspace = DataGen.workspace("ws1", agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a")), - resources = - listOf( - DataGen.resource("agent2", "968eea5e-8787-439d-88cd-5bc440216a34"), - DataGen.resource("agent3", "72fbc97b-952c-40c8-b1e5-7535f4407728"), + workspace = DataGen.workspace( + "ws1", + agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a") ), + resources = + listOf( + DataGen.resource("agent2", "968eea5e-8787-439d-88cd-5bc440216a34"), + DataGen.resource("agent3", "72fbc97b-952c-40c8-b1e5-7535f4407728"), + ), ), ), // Multiple workspaces but only one has resources. listOf( TestWorkspace( - workspace = DataGen.workspace("ws1", agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a")), + workspace = DataGen.workspace( + "ws1", + agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a") + ), resources = emptyList(), ), TestWorkspace( workspace = DataGen.workspace("ws2"), resources = - listOf( - DataGen.resource("agent2", "968eea5e-8787-439d-88cd-5bc440216a34"), - DataGen.resource("agent3", "72fbc97b-952c-40c8-b1e5-7535f4407728"), - ), + listOf( + DataGen.resource("agent2", "968eea5e-8787-439d-88cd-5bc440216a34"), + DataGen.resource("agent3", "72fbc97b-952c-40c8-b1e5-7535f4407728"), + ), ), TestWorkspace( workspace = DataGen.workspace("ws3"), @@ -272,7 +285,8 @@ class CoderRestClientTest { val matches = resourceEndpoint.find(exchange.requestURI.path) if (matches != null) { val templateVersionId = UUID.fromString(matches.destructured.toList()[0]) - val ws = workspaces.firstOrNull { it.workspace.latestBuild.templateVersionID == templateVersionId } + val ws = + workspaces.firstOrNull { it.workspace.latestBuild.templateVersionID == templateVersionId } if (ws != null) { val body = moshi.adapter>( @@ -326,7 +340,8 @@ class CoderRestClientTest { val buildMatch = buildEndpoint.find(exchange.requestURI.path) if (buildMatch != null) { val workspaceId = UUID.fromString(buildMatch.destructured.toList()[0]) - val json = moshi.adapter(CreateWorkspaceBuildRequest::class.java).fromJson(exchange.requestBody.source().buffer()) + val json = moshi.adapter(CreateWorkspaceBuildRequest::class.java) + .fromJson(exchange.requestBody.source().buffer()) if (json == null) { val response = Response("No body", "No body for create workspace build request") val body = moshi.adapter(Response::class.java).toJson(response).toByteArray() @@ -396,8 +411,8 @@ class CoderRestClientTest { CoderSettings( CoderSettingsState( tlsCAPath = Path.of("src/test/fixtures/tls", "self-signed.crt").toString(), - tlsAlternateHostname = "localhost", - ), + tlsAlternateHostname = "localhost" + ) ) val user = DataGen.user() val (srv, url) = mockTLSServer("self-signed") @@ -422,8 +437,8 @@ class CoderRestClientTest { CoderSettings( CoderSettingsState( tlsCAPath = Path.of("src/test/fixtures/tls", "self-signed.crt").toString(), - tlsAlternateHostname = "fake.example.com", - ), + tlsAlternateHostname = "fake.example.com" + ) ) val (srv, url) = mockTLSServer("self-signed") val client = CoderRestClient(URL(url), "token", settings) @@ -441,8 +456,8 @@ class CoderRestClientTest { val settings = CoderSettings( CoderSettingsState( - tlsCAPath = Path.of("src/test/fixtures/tls", "self-signed.crt").toString(), - ), + tlsCAPath = Path.of("src/test/fixtures/tls", "self-signed.crt").toString() + ) ) val (srv, url) = mockTLSServer("no-signing") val client = CoderRestClient(URL(url), "token", settings) @@ -461,7 +476,7 @@ class CoderRestClientTest { CoderSettings( CoderSettingsState( tlsCAPath = Path.of("src/test/fixtures/tls", "chain-root.crt").toString(), - ), + ) ) val user = DataGen.user() val (srv, url) = mockTLSServer("chain") @@ -505,7 +520,8 @@ class CoderRestClientTest { "bar", true, object : ProxySelector() { - override fun select(uri: URI): List = listOf(Proxy(Proxy.Type.HTTP, InetSocketAddress("localhost", srv2.address.port))) + override fun select(uri: URI): List = + listOf(Proxy(Proxy.Type.HTTP, InetSocketAddress("localhost", srv2.address.port))) override fun connectFailed( uri: URI, diff --git a/src/test/kotlin/com/coder/gateway/settings/CoderSettingsTest.kt b/src/test/kotlin/com/coder/gateway/settings/CoderSettingsTest.kt index e98c1e78..71447db5 100644 --- a/src/test/kotlin/com/coder/gateway/settings/CoderSettingsTest.kt +++ b/src/test/kotlin/com/coder/gateway/settings/CoderSettingsTest.kt @@ -63,7 +63,7 @@ internal class CoderSettingsTest { "HOME" to "/tmp/coder-gateway-test/home", "XDG_DATA_HOME" to "/tmp/coder-gateway-test/xdg-data", ), - ), + ) ) var expected = when (getOS()) { @@ -408,7 +408,7 @@ internal class CoderSettingsTest { disableAutostart = getOS() != OS.MAC, setupCommand = "test setup", ignoreSetupFailure = true, - sshLogDirectory = "test ssh log directory", + sshLogDirectory = "test ssh log directory" ), )