Skip to content

impl: add support for disabling CLI signature verification #564

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jul 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

## Unreleased

### Added

- support for skipping CLI signature verification

## 2.22.0 - 2025-07-25

### Added
Expand Down
64 changes: 64 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 30 additions & 18 deletions src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<JBCheckBox>
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)
Expand Down Expand Up @@ -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")) {
Expand Down Expand Up @@ -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")) {
Expand Down
5 changes: 5 additions & 0 deletions src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,11 @@
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)
Expand Down Expand Up @@ -269,7 +274,7 @@

else -> {
val failure = result as VerificationResult.Failed
UnsignedBinaryExecutionDeniedException(result.error.message)

Check warning on line 277 in src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Throwable not thrown

Throwable instance 'UnsignedBinaryExecutionDeniedException' is not thrown
logger.error("Failed to verify signature for ${cliResult.dst}", failure.error)
}
}
Expand Down Expand Up @@ -565,7 +570,7 @@
coderConfigPath.toString(),
"start",
"--yes",
workspaceOwner + "/" + workspaceName

Check notice on line 573 in src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

String concatenation that can be converted to string template

'String' concatenation can be converted to a template
)

if (feats.buildReason) {
Expand Down Expand Up @@ -607,7 +612,7 @@
/*
* This function returns the ssh-host-prefix used for Host entries.
*/
fun getHostPrefix(): String = "coder-jetbrains-${deploymentURL.safeHost()}"

Check notice on line 615 in src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Class member can have 'private' visibility

Function 'getHostPrefix' could be private

/**
* This function returns the ssh host name generated for connecting to the workspace.
Expand Down Expand Up @@ -663,7 +668,7 @@
}
// non-wildcard case
if (parts[0] == "coder-jetbrains") {
return hostname + "--bg"

Check notice on line 671 in src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

String concatenation that can be converted to string template

'String' concatenation can be converted to a template
}
// wildcard case
parts[0] += "-bg"
Expand Down
17 changes: 14 additions & 3 deletions src/main/kotlin/com/coder/gateway/settings/CoderSettings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -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
)

/**
Expand Down Expand Up @@ -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)

Expand All @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@
private fun clearErrorState() {
tfUrlComment?.apply {
foreground = UIUtil.getContextHelpForeground()
if (tfUrl?.text.equals(client?.url?.toString())) {

Check notice on line 234 in src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Return or assignment can be lifted out

'Assignment' can be lifted out of 'if'
text =
CoderGatewayBundle.message(
"gateway.connector.view.coder.workspaces.connect.text.connected",
Expand Down Expand Up @@ -306,7 +306,7 @@
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 {
Expand Down Expand Up @@ -981,7 +981,7 @@

private class WorkspaceVersionColumnInfo(columnName: String) :
ColumnInfo<WorkspaceAgentListModel, String>(columnName) {
override fun valueOf(workspace: WorkspaceAgentListModel?): String? = if (workspace == null) {

Check warning on line 984 in src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Redundant nullable return type

'valueOf' always returns non-null type
"Unknown"
} else if (workspace.workspace.outdated) {
"Outdated"
Expand Down
4 changes: 2 additions & 2 deletions src/main/resources/messages/CoderGatewayBundle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
58 changes: 37 additions & 21 deletions src/test/kotlin/com/coder/gateway/sdk/CoderRestClientTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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<List<WorkspaceResource>>(
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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")
Expand Down Expand Up @@ -505,7 +520,8 @@ class CoderRestClientTest {
"bar",
true,
object : ProxySelector() {
override fun select(uri: URI): List<Proxy> = listOf(Proxy(Proxy.Type.HTTP, InetSocketAddress("localhost", srv2.address.port)))
override fun select(uri: URI): List<Proxy> =
listOf(Proxy(Proxy.Type.HTTP, InetSocketAddress("localhost", srv2.address.port)))

override fun connectFailed(
uri: URI,
Expand Down
Loading
Loading