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 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: 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 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/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) diff --git a/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt b/src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt index 31d64d9c..aa517746 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, @@ -109,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 ) /** @@ -137,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) @@ -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/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 { 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 \ 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" ), )